diff --git a/backend/.env.example b/backend/.env.example index c5ee655b31..15e34da122 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -64,5 +64,10 @@ AWS_USE_PATH_STYLE_ENDPOINT=true JWT_SECRET=2hoccgHb9r1fqW1lU16C6khSHVa7O0eai6FxkWK95UtQ0LqNDTO5mq1RzDwcq18I JWT_ALGO=HS256 +# SES Bounce/Complaint Handling +# AWS_SNS_TOPIC_ARN=arn:aws:sns:us-east-1:ACCOUNT:ses-notifications +# AWS_SNS_VERIFY_SIGNATURE=true +# SES_SUPPRESSION_ENABLED=false + # Only required for SAAS mode and if your're charging fees # OPEN_EXCHANGE_RATES_APP_ID= diff --git a/backend/app/DomainObjects/EmailSuppressionDomainObject.php b/backend/app/DomainObjects/EmailSuppressionDomainObject.php new file mode 100644 index 0000000000..c3f321b6d8 --- /dev/null +++ b/backend/app/DomainObjects/EmailSuppressionDomainObject.php @@ -0,0 +1,7 @@ + $this->id ?? null, + 'account_id' => $this->account_id ?? null, + 'email' => $this->email ?? null, + 'reason' => $this->reason ?? null, + 'bounce_type' => $this->bounce_type ?? null, + 'bounce_sub_type' => $this->bounce_sub_type ?? null, + 'complaint_type' => $this->complaint_type ?? null, + 'source' => $this->source ?? null, + 'sns_message_id' => $this->sns_message_id ?? null, + 'raw_payload' => $this->raw_payload ?? null, + 'created_at' => $this->created_at ?? null, + 'updated_at' => $this->updated_at ?? null, + 'deleted_at' => $this->deleted_at ?? null, + ]; + } + + public function setId(int $id): self + { + $this->id = $id; + return $this; + } + + public function getId(): int + { + return $this->id; + } + + public function setAccountId(?int $account_id): self + { + $this->account_id = $account_id; + return $this; + } + + public function getAccountId(): ?int + { + return $this->account_id; + } + + public function setEmail(string $email): self + { + $this->email = $email; + return $this; + } + + public function getEmail(): string + { + return $this->email; + } + + public function setReason(string $reason): self + { + $this->reason = $reason; + return $this; + } + + public function getReason(): string + { + return $this->reason; + } + + public function setBounceType(?string $bounce_type): self + { + $this->bounce_type = $bounce_type; + return $this; + } + + public function getBounceType(): ?string + { + return $this->bounce_type; + } + + public function setBounceSubType(?string $bounce_sub_type): self + { + $this->bounce_sub_type = $bounce_sub_type; + return $this; + } + + public function getBounceSubType(): ?string + { + return $this->bounce_sub_type; + } + + public function setComplaintType(?string $complaint_type): self + { + $this->complaint_type = $complaint_type; + return $this; + } + + public function getComplaintType(): ?string + { + return $this->complaint_type; + } + + public function setSource(string $source): self + { + $this->source = $source; + return $this; + } + + public function getSource(): string + { + return $this->source; + } + + public function setSnsMessageId(?string $sns_message_id): self + { + $this->sns_message_id = $sns_message_id; + return $this; + } + + public function getSnsMessageId(): ?string + { + return $this->sns_message_id; + } + + public function setRawPayload(array|string|null $raw_payload): self + { + $this->raw_payload = $raw_payload; + return $this; + } + + public function getRawPayload(): array|string|null + { + return $this->raw_payload; + } + + public function setCreatedAt(?string $created_at): self + { + $this->created_at = $created_at; + return $this; + } + + public function getCreatedAt(): ?string + { + return $this->created_at; + } + + public function setUpdatedAt(?string $updated_at): self + { + $this->updated_at = $updated_at; + return $this; + } + + public function getUpdatedAt(): ?string + { + return $this->updated_at; + } + + public function setDeletedAt(?string $deleted_at): self + { + $this->deleted_at = $deleted_at; + return $this; + } + + public function getDeletedAt(): ?string + { + return $this->deleted_at; + } +} diff --git a/backend/app/DomainObjects/Generated/OutgoingMessageDomainObjectAbstract.php b/backend/app/DomainObjects/Generated/OutgoingMessageDomainObjectAbstract.php index 8375d808fd..309f8571f7 100644 --- a/backend/app/DomainObjects/Generated/OutgoingMessageDomainObjectAbstract.php +++ b/backend/app/DomainObjects/Generated/OutgoingMessageDomainObjectAbstract.php @@ -19,6 +19,10 @@ abstract class OutgoingMessageDomainObjectAbstract extends \HiEvents\DomainObjec final public const CREATED_AT = 'created_at'; final public const UPDATED_AT = 'updated_at'; final public const DELETED_AT = 'deleted_at'; + final public const SES_MESSAGE_ID = 'ses_message_id'; + final public const RESOLVED_AT = 'resolved_at'; + final public const RETRY_FOR_ID = 'retry_for_id'; + final public const RESOLUTION_TYPE = 'resolution_type'; protected int $id; protected int $event_id; @@ -29,6 +33,10 @@ abstract class OutgoingMessageDomainObjectAbstract extends \HiEvents\DomainObjec protected ?string $created_at = null; protected ?string $updated_at = null; protected ?string $deleted_at = null; + protected ?string $ses_message_id = null; + protected ?string $resolved_at = null; + protected ?int $retry_for_id = null; + protected ?string $resolution_type = null; public function toArray(): array { @@ -42,6 +50,10 @@ public function toArray(): array 'created_at' => $this->created_at ?? null, 'updated_at' => $this->updated_at ?? null, 'deleted_at' => $this->deleted_at ?? null, + 'ses_message_id' => $this->ses_message_id ?? null, + 'resolved_at' => $this->resolved_at ?? null, + 'retry_for_id' => $this->retry_for_id ?? null, + 'resolution_type' => $this->resolution_type ?? null, ]; } @@ -143,4 +155,48 @@ public function getDeletedAt(): ?string { return $this->deleted_at; } + + public function setSesMessageId(?string $ses_message_id): self + { + $this->ses_message_id = $ses_message_id; + return $this; + } + + public function getSesMessageId(): ?string + { + return $this->ses_message_id; + } + + public function setResolvedAt(?string $resolved_at): self + { + $this->resolved_at = $resolved_at; + return $this; + } + + public function getResolvedAt(): ?string + { + return $this->resolved_at; + } + + public function setRetryForId(?int $retry_for_id): self + { + $this->retry_for_id = $retry_for_id; + return $this; + } + + public function getRetryForId(): ?int + { + return $this->retry_for_id; + } + + public function setResolutionType(?string $resolution_type): self + { + $this->resolution_type = $resolution_type; + return $this; + } + + public function getResolutionType(): ?string + { + return $this->resolution_type; + } } diff --git a/backend/app/DomainObjects/Generated/OutgoingTransactionMessageDomainObjectAbstract.php b/backend/app/DomainObjects/Generated/OutgoingTransactionMessageDomainObjectAbstract.php new file mode 100644 index 0000000000..8bafd282c6 --- /dev/null +++ b/backend/app/DomainObjects/Generated/OutgoingTransactionMessageDomainObjectAbstract.php @@ -0,0 +1,230 @@ + $this->id ?? null, + 'event_id' => $this->event_id ?? null, + 'order_id' => $this->order_id ?? null, + 'attendee_id' => $this->attendee_id ?? null, + 'email_type' => $this->email_type ?? null, + 'recipient' => $this->recipient ?? null, + 'subject' => $this->subject ?? null, + 'status' => $this->status ?? null, + 'created_at' => $this->created_at ?? null, + 'updated_at' => $this->updated_at ?? null, + 'deleted_at' => $this->deleted_at ?? null, + 'ses_message_id' => $this->ses_message_id ?? null, + 'resolved_at' => $this->resolved_at ?? null, + 'retry_for_id' => $this->retry_for_id ?? null, + 'resolution_type' => $this->resolution_type ?? null, + ]; + } + + public function setId(int $id): self + { + $this->id = $id; + return $this; + } + + public function getId(): int + { + return $this->id; + } + + public function setEventId(?int $event_id): self + { + $this->event_id = $event_id; + return $this; + } + + public function getEventId(): ?int + { + return $this->event_id; + } + + public function setOrderId(?int $order_id): self + { + $this->order_id = $order_id; + return $this; + } + + public function getOrderId(): ?int + { + return $this->order_id; + } + + public function setAttendeeId(?int $attendee_id): self + { + $this->attendee_id = $attendee_id; + return $this; + } + + public function getAttendeeId(): ?int + { + return $this->attendee_id; + } + + public function setEmailType(string $email_type): self + { + $this->email_type = $email_type; + return $this; + } + + public function getEmailType(): string + { + return $this->email_type; + } + + public function setRecipient(string $recipient): self + { + $this->recipient = $recipient; + return $this; + } + + public function getRecipient(): string + { + return $this->recipient; + } + + public function setSubject(string $subject): self + { + $this->subject = $subject; + return $this; + } + + public function getSubject(): string + { + return $this->subject; + } + + public function setStatus(string $status): self + { + $this->status = $status; + return $this; + } + + public function getStatus(): string + { + return $this->status; + } + + public function setCreatedAt(?string $created_at): self + { + $this->created_at = $created_at; + return $this; + } + + public function getCreatedAt(): ?string + { + return $this->created_at; + } + + public function setUpdatedAt(?string $updated_at): self + { + $this->updated_at = $updated_at; + return $this; + } + + public function getUpdatedAt(): ?string + { + return $this->updated_at; + } + + public function setDeletedAt(?string $deleted_at): self + { + $this->deleted_at = $deleted_at; + return $this; + } + + public function getDeletedAt(): ?string + { + return $this->deleted_at; + } + + public function setSesMessageId(?string $ses_message_id): self + { + $this->ses_message_id = $ses_message_id; + return $this; + } + + public function getSesMessageId(): ?string + { + return $this->ses_message_id; + } + + public function setResolvedAt(?string $resolved_at): self + { + $this->resolved_at = $resolved_at; + return $this; + } + + public function getResolvedAt(): ?string + { + return $this->resolved_at; + } + + public function setRetryForId(?int $retry_for_id): self + { + $this->retry_for_id = $retry_for_id; + return $this; + } + + public function getRetryForId(): ?int + { + return $this->retry_for_id; + } + + public function setResolutionType(?string $resolution_type): self + { + $this->resolution_type = $resolution_type; + return $this; + } + + public function getResolutionType(): ?string + { + return $this->resolution_type; + } +} diff --git a/backend/app/DomainObjects/Generated/WebhookDomainObjectAbstract.php b/backend/app/DomainObjects/Generated/WebhookDomainObjectAbstract.php index 660f7a66f5..8e301915fd 100644 --- a/backend/app/DomainObjects/Generated/WebhookDomainObjectAbstract.php +++ b/backend/app/DomainObjects/Generated/WebhookDomainObjectAbstract.php @@ -13,8 +13,8 @@ abstract class WebhookDomainObjectAbstract extends \HiEvents\DomainObjects\Abstr final public const ID = 'id'; final public const USER_ID = 'user_id'; final public const EVENT_ID = 'event_id'; - final public const ORGANIZER_ID = 'organizer_id'; final public const ACCOUNT_ID = 'account_id'; + final public const ORGANIZER_ID = 'organizer_id'; final public const URL = 'url'; final public const EVENT_TYPES = 'event_types'; final public const LAST_RESPONSE_CODE = 'last_response_code'; @@ -29,8 +29,8 @@ abstract class WebhookDomainObjectAbstract extends \HiEvents\DomainObjects\Abstr protected int $id; protected int $user_id; protected ?int $event_id = null; - protected ?int $organizer_id = null; protected int $account_id; + protected ?int $organizer_id = null; protected string $url; protected array|string $event_types; protected ?int $last_response_code = null; @@ -48,8 +48,8 @@ public function toArray(): array 'id' => $this->id ?? null, 'user_id' => $this->user_id ?? null, 'event_id' => $this->event_id ?? null, - 'organizer_id' => $this->organizer_id ?? null, 'account_id' => $this->account_id ?? null, + 'organizer_id' => $this->organizer_id ?? null, 'url' => $this->url ?? null, 'event_types' => $this->event_types ?? null, 'last_response_code' => $this->last_response_code ?? null, @@ -96,26 +96,26 @@ public function getEventId(): ?int return $this->event_id; } - public function setOrganizerId(?int $organizer_id): self + public function setAccountId(int $account_id): self { - $this->organizer_id = $organizer_id; + $this->account_id = $account_id; return $this; } - public function getOrganizerId(): ?int + public function getAccountId(): int { - return $this->organizer_id; + return $this->account_id; } - public function setAccountId(int $account_id): self + public function setOrganizerId(?int $organizer_id): self { - $this->account_id = $account_id; + $this->organizer_id = $organizer_id; return $this; } - public function getAccountId(): int + public function getOrganizerId(): ?int { - return $this->account_id; + return $this->organizer_id; } public function setUrl(string $url): self diff --git a/backend/app/DomainObjects/OutgoingMessageDomainObject.php b/backend/app/DomainObjects/OutgoingMessageDomainObject.php index 25d613fd46..1358e62e98 100644 --- a/backend/app/DomainObjects/OutgoingMessageDomainObject.php +++ b/backend/app/DomainObjects/OutgoingMessageDomainObject.php @@ -4,4 +4,64 @@ class OutgoingMessageDomainObject extends Generated\OutgoingMessageDomainObjectAbstract { + protected int $retry_count = 0; + protected ?string $latest_retry_recipient = null; + protected ?string $latest_retry_status = null; + protected ?string $original_recipient = null; + protected ?string $original_status = null; + + public function setRetryCount(int $retry_count): self + { + $this->retry_count = $retry_count; + return $this; + } + + public function getRetryCount(): int + { + return $this->retry_count; + } + + public function setLatestRetryRecipient(?string $latest_retry_recipient): self + { + $this->latest_retry_recipient = $latest_retry_recipient; + return $this; + } + + public function getLatestRetryRecipient(): ?string + { + return $this->latest_retry_recipient; + } + + public function setLatestRetryStatus(?string $latest_retry_status): self + { + $this->latest_retry_status = $latest_retry_status; + return $this; + } + + public function getLatestRetryStatus(): ?string + { + return $this->latest_retry_status; + } + + public function setOriginalRecipient(?string $original_recipient): self + { + $this->original_recipient = $original_recipient; + return $this; + } + + public function getOriginalRecipient(): ?string + { + return $this->original_recipient; + } + + public function setOriginalStatus(?string $original_status): self + { + $this->original_status = $original_status; + return $this; + } + + public function getOriginalStatus(): ?string + { + return $this->original_status; + } } diff --git a/backend/app/DomainObjects/OutgoingTransactionMessageDomainObject.php b/backend/app/DomainObjects/OutgoingTransactionMessageDomainObject.php new file mode 100644 index 0000000000..0c133610ff --- /dev/null +++ b/backend/app/DomainObjects/OutgoingTransactionMessageDomainObject.php @@ -0,0 +1,67 @@ +retry_count = $retry_count; + return $this; + } + + public function getRetryCount(): int + { + return $this->retry_count; + } + + public function setLatestRetryRecipient(?string $latest_retry_recipient): self + { + $this->latest_retry_recipient = $latest_retry_recipient; + return $this; + } + + public function getLatestRetryRecipient(): ?string + { + return $this->latest_retry_recipient; + } + + public function setLatestRetryStatus(?string $latest_retry_status): self + { + $this->latest_retry_status = $latest_retry_status; + return $this; + } + + public function getLatestRetryStatus(): ?string + { + return $this->latest_retry_status; + } + + public function setOriginalRecipient(?string $original_recipient): self + { + $this->original_recipient = $original_recipient; + return $this; + } + + public function getOriginalRecipient(): ?string + { + return $this->original_recipient; + } + + public function setOriginalStatus(?string $original_status): self + { + $this->original_status = $original_status; + return $this; + } + + public function getOriginalStatus(): ?string + { + return $this->original_status; + } +} diff --git a/backend/app/DomainObjects/Status/EmailSuppressionReasonEnum.php b/backend/app/DomainObjects/Status/EmailSuppressionReasonEnum.php new file mode 100644 index 0000000000..613632ffda --- /dev/null +++ b/backend/app/DomainObjects/Status/EmailSuppressionReasonEnum.php @@ -0,0 +1,9 @@ +minimumAllowedRole(Role::SUPERADMIN); + + $validated = $request->validate([ + 'email' => ['required', 'email', 'max:255'], + 'reason' => ['required', 'in:' . implode(',', array_column(EmailSuppressionReasonEnum::cases(), 'value'))], + 'bounce_type' => ['nullable', 'string', 'max:50'], + 'complaint_type' => ['nullable', 'string', 'max:50'], + ]); + + $suppression = $this->emailSuppressionService->suppressEmail( + email: $validated['email'], + reason: $validated['reason'], + source: EmailSuppressionSourceEnum::MANUAL->value, + bounceType: $validated['bounce_type'] ?? null, + complaintType: $validated['complaint_type'] ?? null, + ); + + return $this->resourceResponse( + resource: EmailSuppressionResource::class, + data: $suppression, + statusCode: 201, + ); + } +} diff --git a/backend/app/Http/Actions/Admin/EmailSuppressions/DeleteEmailSuppressionAction.php b/backend/app/Http/Actions/Admin/EmailSuppressions/DeleteEmailSuppressionAction.php new file mode 100644 index 0000000000..c0a69406ca --- /dev/null +++ b/backend/app/Http/Actions/Admin/EmailSuppressions/DeleteEmailSuppressionAction.php @@ -0,0 +1,27 @@ +minimumAllowedRole(Role::SUPERADMIN); + + $this->emailSuppressionService->removeSuppressionById($suppression_id); + + return $this->deletedResponse(); + } +} diff --git a/backend/app/Http/Actions/Admin/EmailSuppressions/GetAllEmailSuppressionsAction.php b/backend/app/Http/Actions/Admin/EmailSuppressions/GetAllEmailSuppressionsAction.php new file mode 100644 index 0000000000..393f1d7a52 --- /dev/null +++ b/backend/app/Http/Actions/Admin/EmailSuppressions/GetAllEmailSuppressionsAction.php @@ -0,0 +1,40 @@ +minimumAllowedRole(Role::SUPERADMIN); + + $suppressions = $this->handler->handle(new GetAllEmailSuppressionsDTO( + perPage: min((int)$request->query('per_page', 20), 100), + search: $request->query('search'), + reason: $request->query('reason'), + source: $request->query('source'), + sortBy: $request->query('sort_by', 'created_at'), + sortDirection: $request->query('sort_direction', 'desc'), + )); + + return $this->resourceResponse( + resource: EmailSuppressionResource::class, + data: $suppressions, + ); + } +} diff --git a/backend/app/Http/Actions/Common/Webhooks/SesIncomingWebhookAction.php b/backend/app/Http/Actions/Common/Webhooks/SesIncomingWebhookAction.php new file mode 100644 index 0000000000..78977342db --- /dev/null +++ b/backend/app/Http/Actions/Common/Webhooks/SesIncomingWebhookAction.php @@ -0,0 +1,31 @@ +handle(new SesWebhookDTO( + payload: $request->getContent(), + )); + } catch (Throwable $exception) { + logger()?->error(__('Failed to handle incoming SES webhook'), [ + 'exception' => $exception, + 'payload' => $request->getContent(), + ]); + return $this->noContentResponse(ResponseCodes::HTTP_BAD_REQUEST); + } + + return $this->noContentResponse(); + } +} diff --git a/backend/app/Http/Actions/DeliveryIssue/GetDeliveryIssuesAction.php b/backend/app/Http/Actions/DeliveryIssue/GetDeliveryIssuesAction.php new file mode 100644 index 0000000000..9ced660082 --- /dev/null +++ b/backend/app/Http/Actions/DeliveryIssue/GetDeliveryIssuesAction.php @@ -0,0 +1,36 @@ +isActionAuthorized($eventId, EventDomainObject::class); + + $params = $this->getPaginationQueryParams($request); + $showResolved = $request->boolean('show_resolved', false); + + $results = $this->handler->handle( + eventId: $eventId, + perPage: $params->per_page ?? 20, + page: $params->page ?? 1, + showResolved: $showResolved, + ); + + return $this->resourceResponse(DeliveryIssueResource::class, $results); + } +} diff --git a/backend/app/Http/Actions/DeliveryIssue/ResolveDeliveryIssueAction.php b/backend/app/Http/Actions/DeliveryIssue/ResolveDeliveryIssueAction.php new file mode 100644 index 0000000000..5bdb16f0f8 --- /dev/null +++ b/backend/app/Http/Actions/DeliveryIssue/ResolveDeliveryIssueAction.php @@ -0,0 +1,114 @@ +isActionAuthorized($eventId, EventDomainObject::class); + + $this->validate($request, [ + 'source_type' => 'required|in:transaction,announcement', + ]); + + $sourceType = $request->input('source_type'); + + if ($sourceType === 'transaction') { + return $this->resolveTransactionMessage($eventId, $messageId); + } + + return $this->resolveOutgoingMessage($eventId, $messageId); + } + + private function resolveTransactionMessage(int $eventId, int $messageId): JsonResponse + { + $message = $this->transactionMessageRepository->findFirstWhere([ + OutgoingTransactionMessageDomainObjectAbstract::ID => $messageId, + OutgoingTransactionMessageDomainObjectAbstract::EVENT_ID => $eventId, + ]); + + if (!$message) { + return $this->notFoundResponse(); + } + + $isResolving = !$message->getResolvedAt(); + $resolvedAt = $isResolving ? now()->toDateTimeString() : null; + $resolutionType = $isResolving ? 'manual' : null; + + $this->transactionMessageRepository->updateWhere( + attributes: [ + OutgoingTransactionMessageDomainObjectAbstract::RESOLVED_AT => $resolvedAt, + OutgoingTransactionMessageDomainObjectAbstract::RESOLUTION_TYPE => $resolutionType, + ], + where: [OutgoingTransactionMessageDomainObjectAbstract::ID => $messageId], + ); + + $updated = $this->transactionMessageRepository->findById($messageId); + + return $this->jsonResponse([ + 'id' => $updated->getId(), + 'source_type' => 'transaction', + 'email_type' => $updated->getEmailType(), + 'status' => $updated->getStatus(), + 'subject' => $updated->getSubject(), + 'recipient' => $updated->getRecipient(), + 'updated_at' => $updated->getUpdatedAt(), + 'resolved_at' => $updated->getResolvedAt(), + 'resolution_type' => $updated->getResolutionType(), + ]); + } + + private function resolveOutgoingMessage(int $eventId, int $messageId): JsonResponse + { + $message = $this->outgoingMessageRepository->findFirstWhere([ + OutgoingMessageDomainObjectAbstract::ID => $messageId, + OutgoingMessageDomainObjectAbstract::EVENT_ID => $eventId, + ]); + + if (!$message) { + return $this->notFoundResponse(); + } + + $isResolving = !$message->getResolvedAt(); + $resolvedAt = $isResolving ? now()->toDateTimeString() : null; + $resolutionType = $isResolving ? 'manual' : null; + + $this->outgoingMessageRepository->updateWhere( + attributes: [ + OutgoingMessageDomainObjectAbstract::RESOLVED_AT => $resolvedAt, + OutgoingMessageDomainObjectAbstract::RESOLUTION_TYPE => $resolutionType, + ], + where: [OutgoingMessageDomainObjectAbstract::ID => $messageId], + ); + + $updated = $this->outgoingMessageRepository->findById($messageId); + + return $this->jsonResponse([ + 'id' => $updated->getId(), + 'source_type' => 'announcement', + 'email_type' => 'announcement', + 'status' => $updated->getStatus(), + 'subject' => $updated->getSubject(), + 'recipient' => $updated->getRecipient(), + 'updated_at' => $updated->getUpdatedAt(), + 'resolved_at' => $updated->getResolvedAt(), + ]); + } +} diff --git a/backend/app/Http/Actions/Messages/GetOutgoingMessagesAction.php b/backend/app/Http/Actions/Messages/GetOutgoingMessagesAction.php new file mode 100644 index 0000000000..3fba2a2e61 --- /dev/null +++ b/backend/app/Http/Actions/Messages/GetOutgoingMessagesAction.php @@ -0,0 +1,30 @@ +isActionAuthorized($eventId, EventDomainObject::class); + + $params = $this->getPaginationQueryParams($request); + + $messages = $this->repository->getForEvent($eventId, $params); + + return $this->resourceResponse(OutgoingMessageResource::class, $messages); + } +} diff --git a/backend/app/Http/Actions/Messages/ResendOutgoingMessageAction.php b/backend/app/Http/Actions/Messages/ResendOutgoingMessageAction.php new file mode 100644 index 0000000000..a9923fff80 --- /dev/null +++ b/backend/app/Http/Actions/Messages/ResendOutgoingMessageAction.php @@ -0,0 +1,42 @@ +isActionAuthorized($eventId, EventDomainObject::class); + + $this->validate($request, [ + 'email' => 'sometimes|email', + ]); + + $result = $this->handler->handle( + eventId: $eventId, + messageId: $messageId, + newEmail: $request->input('email'), + ); + + return $this->resourceResponse(OutgoingMessageResource::class, $result); + } +} diff --git a/backend/app/Http/Actions/Orders/ResendOrderConfirmationAction.php b/backend/app/Http/Actions/Orders/ResendOrderConfirmationAction.php index c38c6cbac3..80f2e429d5 100644 --- a/backend/app/Http/Actions/Orders/ResendOrderConfirmationAction.php +++ b/backend/app/Http/Actions/Orders/ResendOrderConfirmationAction.php @@ -9,28 +9,22 @@ use HiEvents\DomainObjects\OrderItemDomainObject; use HiEvents\DomainObjects\OrganizerDomainObject; use HiEvents\Http\Actions\BaseAction; -use HiEvents\Mail\Order\OrderSummary; use HiEvents\Repository\Eloquent\Value\Relationship; use HiEvents\Repository\Interfaces\EventRepositoryInterface; use HiEvents\Repository\Interfaces\OrderRepositoryInterface; -use HiEvents\Services\Domain\Email\MailBuilderService; +use HiEvents\Services\Domain\Mail\SendOrderDetailsService; use Illuminate\Http\Response; -use Illuminate\Mail\Mailer; class ResendOrderConfirmationAction extends BaseAction { public function __construct( - private readonly EventRepositoryInterface $eventRepository, - private readonly OrderRepositoryInterface $orderRepository, - private readonly Mailer $mailer, - private readonly MailBuilderService $mailBuilderService, + private readonly EventRepositoryInterface $eventRepository, + private readonly OrderRepositoryInterface $orderRepository, + private readonly SendOrderDetailsService $sendOrderDetailsService, ) { } - /** - * @todo - move this to a handler - */ public function __invoke(int $eventId, int $orderId): Response { $this->isActionAuthorized($eventId, EventDomainObject::class); @@ -53,18 +47,13 @@ public function __invoke(int $eventId, int $orderId): Response ->loadRelation(new Relationship(EventSettingDomainObject::class)) ->findById($order->getEventId()); - $mail = $this->mailBuilderService->buildOrderSummaryMail( - $order, - $event, - $event->getEventSettings(), - $event->getOrganizer(), - $order->getLatestInvoice() + $this->sendOrderDetailsService->sendCustomerOrderSummary( + order: $order, + event: $event, + organizer: $event->getOrganizer(), + eventSettings: $event->getEventSettings(), + invoice: $order->getLatestInvoice(), ); - - $this->mailer - ->to($order->getEmail()) - ->locale($order->getLocale()) - ->send($mail); } return $this->noContentResponse(); diff --git a/backend/app/Http/Actions/TransactionMessages/GetTransactionMessagesAction.php b/backend/app/Http/Actions/TransactionMessages/GetTransactionMessagesAction.php new file mode 100644 index 0000000000..3fdeaa4609 --- /dev/null +++ b/backend/app/Http/Actions/TransactionMessages/GetTransactionMessagesAction.php @@ -0,0 +1,30 @@ +isActionAuthorized($eventId, EventDomainObject::class); + + $params = $this->getPaginationQueryParams($request); + + $messages = $this->repository->getForEvent($eventId, $params); + + return $this->resourceResponse(OutgoingTransactionMessageResource::class, $messages); + } +} diff --git a/backend/app/Http/Actions/TransactionMessages/ResendTransactionMessageAction.php b/backend/app/Http/Actions/TransactionMessages/ResendTransactionMessageAction.php new file mode 100644 index 0000000000..cf39b643a0 --- /dev/null +++ b/backend/app/Http/Actions/TransactionMessages/ResendTransactionMessageAction.php @@ -0,0 +1,41 @@ +isActionAuthorized($eventId, EventDomainObject::class); + + $request->validate([ + 'email' => 'sometimes|nullable|email', + ]); + + try { + $message = $this->handler->handle( + eventId: $eventId, + messageId: $messageId, + newEmail: $request->input('email'), + ); + + return $this->resourceResponse(OutgoingTransactionMessageResource::class, $message); + } catch (ValidationException $e) { + return $this->errorResponse($e->getMessage()); + } + } +} diff --git a/backend/app/Http/Resources/Admin/EmailSuppressionResource.php b/backend/app/Http/Resources/Admin/EmailSuppressionResource.php new file mode 100644 index 0000000000..df2592c775 --- /dev/null +++ b/backend/app/Http/Resources/Admin/EmailSuppressionResource.php @@ -0,0 +1,27 @@ + $this->id, + 'email' => $this->email, + 'reason' => $this->reason, + 'bounce_type' => $this->bounce_type, + 'bounce_sub_type' => $this->bounce_sub_type, + 'complaint_type' => $this->complaint_type, + 'source' => $this->source, + 'account_id' => $this->account_id, + 'account_name' => $this->account_name, + 'created_at' => $this->created_at, + ]; + } +} diff --git a/backend/app/Jobs/Event/SendEventEmailJob.php b/backend/app/Jobs/Event/SendEventEmailJob.php index af6b259ffb..3758c885f6 100644 --- a/backend/app/Jobs/Event/SendEventEmailJob.php +++ b/backend/app/Jobs/Event/SendEventEmailJob.php @@ -7,6 +7,7 @@ use HiEvents\Mail\Event\EventMessage; use HiEvents\Repository\Interfaces\OutgoingMessageRepositoryInterface; use HiEvents\Services\Application\Handlers\Message\DTO\SendMessageDTO; +use HiEvents\Services\Domain\Email\EmailSuppressionService; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Bus\Dispatchable; @@ -34,12 +35,30 @@ public function __construct( public function handle( Mailer $mailer, OutgoingMessageRepositoryInterface $outgoingMessageRepository, + EmailSuppressionService $emailSuppressionService, ): void { + if ($emailSuppressionService->isEmailSuppressed($this->email, $this->messageData->account_id ?? null, 'marketing')) { + logger()->info('Email suppressed, skipping send', [ + 'email' => $this->email, + 'message_id' => $this->messageData->id, + ]); + + $outgoingMessageRepository->create([ + OutgoingMessageDomainObjectAbstract::MESSAGE_ID => $this->messageData->id, + OutgoingMessageDomainObjectAbstract::EVENT_ID => $this->messageData->event_id, + OutgoingMessageDomainObjectAbstract::STATUS => OutgoingMessageStatus::SUPPRESSED->name, + OutgoingMessageDomainObjectAbstract::RECIPIENT => $this->email, + OutgoingMessageDomainObjectAbstract::SUBJECT => $this->messageData->subject, + ]); + + return; + } + try { - $mailer + $sentMessage = $mailer ->to($this->email, $this->toName) - ->send($this->eventMessage); + ->sendNow($this->eventMessage); } catch (Throwable $exception) { $outgoingMessageRepository->create([ OutgoingMessageDomainObjectAbstract::MESSAGE_ID => $this->messageData->id, @@ -58,6 +77,7 @@ public function handle( OutgoingMessageDomainObjectAbstract::STATUS => OutgoingMessageStatus::SENT->name, OutgoingMessageDomainObjectAbstract::RECIPIENT => $this->email, OutgoingMessageDomainObjectAbstract::SUBJECT => $this->messageData->subject, + OutgoingMessageDomainObjectAbstract::SES_MESSAGE_ID => $sentMessage?->getMessageId(), ]); } } diff --git a/backend/app/Jobs/Waitlist/SendWaitlistConfirmationEmailJob.php b/backend/app/Jobs/Waitlist/SendWaitlistConfirmationEmailJob.php index f22fdaa9e4..e2125af685 100644 --- a/backend/app/Jobs/Waitlist/SendWaitlistConfirmationEmailJob.php +++ b/backend/app/Jobs/Waitlist/SendWaitlistConfirmationEmailJob.php @@ -2,6 +2,7 @@ namespace HiEvents\Jobs\Waitlist; +use HiEvents\DomainObjects\Enums\TransactionalEmailType; use HiEvents\DomainObjects\EventSettingDomainObject; use HiEvents\DomainObjects\OrganizerDomainObject; use HiEvents\DomainObjects\WaitlistEntryDomainObject; @@ -10,6 +11,7 @@ use HiEvents\Repository\Interfaces\EventRepositoryInterface; use HiEvents\Repository\Interfaces\ProductPriceRepositoryInterface; use HiEvents\Repository\Interfaces\ProductRepositoryInterface; +use HiEvents\Services\Domain\Email\TransactionalEmailTrackingService; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Bus\Dispatchable; @@ -30,10 +32,11 @@ public function __construct( } public function handle( - EventRepositoryInterface $eventRepository, - ProductPriceRepositoryInterface $productPriceRepository, - ProductRepositoryInterface $productRepository, - Mailer $mailer, + EventRepositoryInterface $eventRepository, + ProductPriceRepositoryInterface $productPriceRepository, + ProductRepositoryInterface $productRepository, + Mailer $mailer, + TransactionalEmailTrackingService $trackingService, ): void { $event = $eventRepository @@ -48,16 +51,24 @@ public function handle( $product = $productRepository->findById($productPrice->getProductId()); } - $mailer - ->to($this->entry->getEmail()) - ->locale($this->entry->getLocale()) - ->send(new WaitlistConfirmationMail( - entry: $this->entry, - event: $event, - product: $product, - productPrice: $productPrice, - organizer: $event->getOrganizer(), - eventSettings: $event->getEventSettings(), - )); + $mail = new WaitlistConfirmationMail( + entry: $this->entry, + event: $event, + product: $product, + productPrice: $productPrice, + organizer: $event->getOrganizer(), + eventSettings: $event->getEventSettings(), + ); + + $trackingService->recordAndSend( + mailer: $mailer, + recipient: $this->entry->getEmail(), + mail: $mail, + emailType: TransactionalEmailType::WAITLIST_CONFIRMATION, + subject: $mail->envelope()->subject, + eventId: $event->getId(), + accountId: $event->getAccountId(), + locale: $this->entry->getLocale(), + ); } } diff --git a/backend/app/Jobs/Waitlist/SendWaitlistOfferEmailJob.php b/backend/app/Jobs/Waitlist/SendWaitlistOfferEmailJob.php index 31a52696aa..666eebc986 100644 --- a/backend/app/Jobs/Waitlist/SendWaitlistOfferEmailJob.php +++ b/backend/app/Jobs/Waitlist/SendWaitlistOfferEmailJob.php @@ -2,6 +2,7 @@ namespace HiEvents\Jobs\Waitlist; +use HiEvents\DomainObjects\Enums\TransactionalEmailType; use HiEvents\DomainObjects\EventSettingDomainObject; use HiEvents\DomainObjects\OrganizerDomainObject; use HiEvents\DomainObjects\WaitlistEntryDomainObject; @@ -10,6 +11,7 @@ use HiEvents\Repository\Interfaces\EventRepositoryInterface; use HiEvents\Repository\Interfaces\ProductPriceRepositoryInterface; use HiEvents\Repository\Interfaces\ProductRepositoryInterface; +use HiEvents\Services\Domain\Email\TransactionalEmailTrackingService; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Bus\Dispatchable; @@ -33,10 +35,11 @@ public function __construct( } public function handle( - EventRepositoryInterface $eventRepository, - ProductPriceRepositoryInterface $productPriceRepository, - ProductRepositoryInterface $productRepository, - Mailer $mailer, + EventRepositoryInterface $eventRepository, + ProductPriceRepositoryInterface $productPriceRepository, + ProductRepositoryInterface $productRepository, + Mailer $mailer, + TransactionalEmailTrackingService $trackingService, ): void { $event = $eventRepository @@ -51,18 +54,26 @@ public function handle( $product = $productRepository->findById($productPrice->getProductId()); } - $mailer - ->to($this->entry->getEmail()) - ->locale($this->entry->getLocale()) - ->send(new WaitlistOfferMail( - entry: $this->entry, - event: $event, - product: $product, - productPrice: $productPrice, - organizer: $event->getOrganizer(), - eventSettings: $event->getEventSettings(), - orderShortId: $this->orderShortId, - sessionIdentifier: $this->sessionIdentifier, - )); + $mail = new WaitlistOfferMail( + entry: $this->entry, + event: $event, + product: $product, + productPrice: $productPrice, + organizer: $event->getOrganizer(), + eventSettings: $event->getEventSettings(), + orderShortId: $this->orderShortId, + sessionIdentifier: $this->sessionIdentifier, + ); + + $trackingService->recordAndSend( + mailer: $mailer, + recipient: $this->entry->getEmail(), + mail: $mail, + emailType: TransactionalEmailType::WAITLIST_OFFER, + subject: $mail->envelope()->subject, + eventId: $event->getId(), + accountId: $event->getAccountId(), + locale: $this->entry->getLocale(), + ); } } diff --git a/backend/app/Jobs/Waitlist/SendWaitlistOfferExpiredEmailJob.php b/backend/app/Jobs/Waitlist/SendWaitlistOfferExpiredEmailJob.php index c47b3f63de..de8bff501e 100644 --- a/backend/app/Jobs/Waitlist/SendWaitlistOfferExpiredEmailJob.php +++ b/backend/app/Jobs/Waitlist/SendWaitlistOfferExpiredEmailJob.php @@ -2,6 +2,7 @@ namespace HiEvents\Jobs\Waitlist; +use HiEvents\DomainObjects\Enums\TransactionalEmailType; use HiEvents\DomainObjects\EventSettingDomainObject; use HiEvents\DomainObjects\OrganizerDomainObject; use HiEvents\DomainObjects\WaitlistEntryDomainObject; @@ -10,6 +11,7 @@ use HiEvents\Repository\Interfaces\EventRepositoryInterface; use HiEvents\Repository\Interfaces\ProductPriceRepositoryInterface; use HiEvents\Repository\Interfaces\ProductRepositoryInterface; +use HiEvents\Services\Domain\Email\TransactionalEmailTrackingService; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Bus\Dispatchable; @@ -30,10 +32,11 @@ public function __construct( } public function handle( - EventRepositoryInterface $eventRepository, - ProductPriceRepositoryInterface $productPriceRepository, - ProductRepositoryInterface $productRepository, - Mailer $mailer, + EventRepositoryInterface $eventRepository, + ProductPriceRepositoryInterface $productPriceRepository, + ProductRepositoryInterface $productRepository, + Mailer $mailer, + TransactionalEmailTrackingService $trackingService, ): void { $event = $eventRepository @@ -48,16 +51,24 @@ public function handle( $product = $productRepository->findById($productPrice->getProductId()); } - $mailer - ->to($this->entry->getEmail()) - ->locale($this->entry->getLocale()) - ->send(new WaitlistOfferExpiredMail( - entry: $this->entry, - event: $event, - product: $product, - productPrice: $productPrice, - organizer: $event->getOrganizer(), - eventSettings: $event->getEventSettings(), - )); + $mail = new WaitlistOfferExpiredMail( + entry: $this->entry, + event: $event, + product: $product, + productPrice: $productPrice, + organizer: $event->getOrganizer(), + eventSettings: $event->getEventSettings(), + ); + + $trackingService->recordAndSend( + mailer: $mailer, + recipient: $this->entry->getEmail(), + mail: $mail, + emailType: TransactionalEmailType::WAITLIST_OFFER_EXPIRED, + subject: $mail->envelope()->subject, + eventId: $event->getId(), + accountId: $event->getAccountId(), + locale: $this->entry->getLocale(), + ); } } diff --git a/backend/app/Mail/BaseMail.php b/backend/app/Mail/BaseMail.php index 447f08df0d..c045ce7254 100644 --- a/backend/app/Mail/BaseMail.php +++ b/backend/app/Mail/BaseMail.php @@ -7,12 +7,15 @@ use Illuminate\Mail\Mailable; use Illuminate\Mail\Mailables\Content; use Illuminate\Mail\Mailables\Envelope; +use Illuminate\Mail\Mailables\Headers; use Illuminate\Queue\SerializesModels; abstract class BaseMail extends Mailable implements ShouldQueue { use Queueable, SerializesModels; + public ?string $retryForSesMessageId = null; + public function __construct() { $this->afterCommit(); @@ -21,4 +24,20 @@ public function __construct() abstract public function envelope(): Envelope; abstract public function content(): Content; + + public function headers(): Headers + { + $text = []; + + $configSet = config('services.ses.configuration_set'); + if ($configSet) { + $text['X-SES-CONFIGURATION-SET'] = $configSet; + } + + if ($this->retryForSesMessageId) { + $text['X-HiEvents-Retry-For'] = $this->retryForSesMessageId; + } + + return new Headers(text: $text); + } } diff --git a/backend/app/Models/EmailSuppression.php b/backend/app/Models/EmailSuppression.php new file mode 100644 index 0000000000..7680653783 --- /dev/null +++ b/backend/app/Models/EmailSuppression.php @@ -0,0 +1,10 @@ + TicketLookupTokenRepository::class, AccountMessagingTierRepositoryInterface::class => AccountMessagingTierRepository::class, WaitlistEntryRepositoryInterface::class => WaitlistEntryRepository::class, + EmailSuppressionRepositoryInterface::class => EmailSuppressionRepository::class, + OutgoingTransactionMessageRepositoryInterface::class => OutgoingTransactionMessageRepository::class, ]; public function register(): void diff --git a/backend/app/Repository/Eloquent/EmailSuppressionRepository.php b/backend/app/Repository/Eloquent/EmailSuppressionRepository.php new file mode 100644 index 0000000000..5483f9c016 --- /dev/null +++ b/backend/app/Repository/Eloquent/EmailSuppressionRepository.php @@ -0,0 +1,68 @@ + + */ +class EmailSuppressionRepository extends BaseRepository implements EmailSuppressionRepositoryInterface +{ + protected function getModel(): string + { + return EmailSuppression::class; + } + + public function getDomainObject(): string + { + return EmailSuppressionDomainObject::class; + } + + public function isEmailSuppressed(string $email, ?int $accountId, string $reason): bool + { + $query = $this->model->newQuery() + ->where('email', strtolower($email)) + ->where('reason', $reason) + ->whereNull('deleted_at'); + + if ($accountId !== null) { + $query->where(function ($q) use ($accountId) { + $q->where('account_id', $accountId) + ->orWhereNull('account_id'); + }); + } + + return $query->exists(); + } + + public function findByEmail(string $email, ?int $accountId = null): Collection + { + $query = $this->model->newQuery() + ->where('email', strtolower($email)) + ->whereNull('deleted_at'); + + if ($accountId !== null) { + $query->where(function ($q) use ($accountId) { + $q->where('account_id', $accountId) + ->orWhereNull('account_id'); + }); + } + + return $this->handleResults($query->get()); + } + + public function findOrCreateSuppression(array $uniqueAttributes, array $additionalValues): EmailSuppressionDomainObject + { + $model = $this->model->newQuery() + ->whereNull('deleted_at') + ->updateOrCreate($uniqueAttributes, $additionalValues); + + $this->resetModel(); + + return $this->handleSingleResult($model); + } +} diff --git a/backend/app/Repository/Eloquent/OutgoingMessageRepository.php b/backend/app/Repository/Eloquent/OutgoingMessageRepository.php index 119d0251dc..0b16962a73 100644 --- a/backend/app/Repository/Eloquent/OutgoingMessageRepository.php +++ b/backend/app/Repository/Eloquent/OutgoingMessageRepository.php @@ -3,8 +3,12 @@ namespace HiEvents\Repository\Eloquent; use HiEvents\DomainObjects\OutgoingMessageDomainObject; +use HiEvents\DomainObjects\Status\OutgoingMessageStatus; +use HiEvents\Http\DTO\QueryParamsDTO; use HiEvents\Models\OutgoingMessage; use HiEvents\Repository\Interfaces\OutgoingMessageRepositoryInterface; +use Illuminate\Pagination\LengthAwarePaginator; +use Illuminate\Support\Facades\DB; /** * @extends BaseRepository @@ -20,4 +24,93 @@ public function getDomainObject(): string { return OutgoingMessageDomainObject::class; } + + public function findAccountIdByRecipientEmail(string $email): ?int + { + $result = DB::table('outgoing_messages') + ->join('events', 'outgoing_messages.event_id', '=', 'events.id') + ->where('outgoing_messages.recipient', strtolower($email)) + ->orderByDesc('outgoing_messages.created_at') + ->select('events.account_id') + ->first(); + + return $result?->account_id; + } + + public function markAsBounced(string $sesMessageId): bool + { + $affected = DB::table('outgoing_messages') + ->where('ses_message_id', $sesMessageId) + ->whereIn('status', [OutgoingMessageStatus::SENT->name, OutgoingMessageStatus::DELIVERED->name]) + ->update(['status' => OutgoingMessageStatus::BOUNCED->name]); + + return $affected > 0; + } + + public function getForEvent(int $eventId, QueryParamsDTO $params): LengthAwarePaginator + { + $table = 'outgoing_messages'; + $query = OutgoingMessage::query() + ->where('event_id', $eventId) + ->selectRaw("*, + (SELECT COUNT(*) FROM {$table} r WHERE r.retry_for_id = {$table}.id) as retry_count, + (SELECT r2.recipient FROM {$table} r2 WHERE r2.retry_for_id = {$table}.id ORDER BY r2.created_at DESC LIMIT 1) as latest_retry_recipient, + (SELECT r3.status FROM {$table} r3 WHERE r3.retry_for_id = {$table}.id ORDER BY r3.created_at DESC LIMIT 1) as latest_retry_status, + (SELECT o.recipient FROM {$table} o WHERE o.id = {$table}.retry_for_id) as original_recipient, + (SELECT o2.status FROM {$table} o2 WHERE o2.id = {$table}.retry_for_id) as original_status"); + + if ($params->query) { + $search = '%' . $params->query . '%'; + $query->where(function ($q) use ($search) { + $q->where('recipient', 'ilike', $search) + ->orWhere('subject', 'ilike', $search); + }); + } + + if ($params->filter_fields) { + foreach ($params->filter_fields as $filter) { + if ($filter->field === 'status' && !empty($filter->value)) { + $values = is_array($filter->value) ? $filter->value : explode(',', $filter->value); + if (in_array('RESOLVED', $values)) { + $otherValues = array_filter($values, fn($v) => $v !== 'RESOLVED'); + $query->where(function ($q) use ($otherValues) { + $q->whereNotNull('resolved_at'); + if (!empty($otherValues)) { + $q->orWhereIn('status', $otherValues); + } + }); + } else { + $query->whereIn('status', $values); + } + } + if ($filter->field === 'date_range' && !empty($filter->value)) { + $days = match ($filter->value) { + '1d' => 1, '7d' => 7, '30d' => 30, '90d' => 90, '365d' => 365, default => null, + }; + if ($days) { + $query->where('created_at', '>=', now()->subDays($days)); + } + } + } + } + + $allowedSortFields = ['created_at', 'updated_at', 'status', 'recipient', 'subject']; + $sortBy = in_array($params->sort_by, $allowedSortFields) ? $params->sort_by : 'created_at'; + $sortDir = strtolower($params->sort_direction ?? 'desc') === 'asc' ? 'asc' : 'desc'; + $query->orderBy($sortBy, $sortDir); + + $results = $query->paginate(perPage: $params->per_page ?? 20, page: $params->page); + + return $this->handleResults($results); + } + + public function markAsDelivered(string $sesMessageId): bool + { + $affected = DB::table('outgoing_messages') + ->where('ses_message_id', $sesMessageId) + ->where('status', OutgoingMessageStatus::SENT->name) + ->update(['status' => OutgoingMessageStatus::DELIVERED->name]); + + return $affected > 0; + } } diff --git a/backend/app/Repository/Eloquent/OutgoingTransactionMessageRepository.php b/backend/app/Repository/Eloquent/OutgoingTransactionMessageRepository.php new file mode 100644 index 0000000000..a8aba64906 --- /dev/null +++ b/backend/app/Repository/Eloquent/OutgoingTransactionMessageRepository.php @@ -0,0 +1,149 @@ + + */ +class OutgoingTransactionMessageRepository extends BaseRepository implements OutgoingTransactionMessageRepositoryInterface +{ + protected function getModel(): string + { + return OutgoingTransactionMessage::class; + } + + public function getDomainObject(): string + { + return OutgoingTransactionMessageDomainObject::class; + } + + public function findBySesMessageId(string $sesMessageId): ?OutgoingTransactionMessageDomainObject + { + return $this->findFirstWhere([ + 'ses_message_id' => $sesMessageId, + ['status', 'in', [OutgoingTransactionMessageStatus::SENT->value, OutgoingTransactionMessageStatus::DELIVERED->value]], + ]); + } + + public function markAsBounced(int $id): void + { + $this->updateWhere( + attributes: ['status' => OutgoingTransactionMessageStatus::BOUNCED->value], + where: ['id' => $id], + ); + } + + public function markAsDelivered(int $id): void + { + $this->updateWhere( + attributes: ['status' => OutgoingTransactionMessageStatus::DELIVERED->value], + where: ['id' => $id], + ); + } + + public function getForEvent(int $eventId, QueryParamsDTO $params): LengthAwarePaginator + { + $table = 'outgoing_transaction_messages'; + $query = OutgoingTransactionMessage::query() + ->where('event_id', $eventId) + ->selectRaw("*, + (SELECT COUNT(*) FROM {$table} r WHERE r.retry_for_id = {$table}.id) as retry_count, + (SELECT r2.recipient FROM {$table} r2 WHERE r2.retry_for_id = {$table}.id ORDER BY r2.created_at DESC LIMIT 1) as latest_retry_recipient, + (SELECT r3.status FROM {$table} r3 WHERE r3.retry_for_id = {$table}.id ORDER BY r3.created_at DESC LIMIT 1) as latest_retry_status, + (SELECT o.recipient FROM {$table} o WHERE o.id = {$table}.retry_for_id) as original_recipient, + (SELECT o2.status FROM {$table} o2 WHERE o2.id = {$table}.retry_for_id) as original_status"); + + if ($params->query) { + $search = '%' . $params->query . '%'; + $query->where(function ($q) use ($search) { + $q->where('recipient', 'ilike', $search) + ->orWhere('subject', 'ilike', $search); + }); + } + + if ($params->filter_fields) { + foreach ($params->filter_fields as $filter) { + if ($filter->field === 'status' && !empty($filter->value)) { + $values = is_array($filter->value) ? $filter->value : explode(',', $filter->value); + if (in_array('RESOLVED', $values)) { + $otherValues = array_filter($values, fn($v) => $v !== 'RESOLVED'); + $query->where(function ($q) use ($otherValues) { + $q->whereNotNull('resolved_at'); + if (!empty($otherValues)) { + $q->orWhereIn('status', $otherValues); + } + }); + } else { + $query->whereIn('status', $values); + } + } + if ($filter->field === 'email_type' && !empty($filter->value)) { + $values = is_array($filter->value) ? $filter->value : explode(',', $filter->value); + $query->whereIn('email_type', $values); + } + if ($filter->field === 'date_range' && !empty($filter->value)) { + $days = match ($filter->value) { + '1d' => 1, '7d' => 7, '30d' => 30, '90d' => 90, '365d' => 365, default => null, + }; + if ($days) { + $query->where('created_at', '>=', now()->subDays($days)); + } + } + } + } + + $allowedSortFields = ['created_at', 'updated_at', 'status', 'recipient', 'subject', 'email_type']; + $sortBy = in_array($params->sort_by, $allowedSortFields) ? $params->sort_by : 'created_at'; + $sortDir = strtolower($params->sort_direction ?? 'desc') === 'asc' ? 'asc' : 'desc'; + $query->orderBy($sortBy, $sortDir); + + $results = $query->paginate(perPage: $params->per_page ?? 20, page: $params->page); + + return $this->handleResults($results); + } + + public function getFailuresForEvent(int $eventId, int $perPage = 20, bool $showResolved = false): LengthAwarePaginator + { + $where = [ + 'event_id' => $eventId, + [ + 'status', + 'in', + [ + OutgoingTransactionMessageStatus::BOUNCED->value, + OutgoingTransactionMessageStatus::FAILED->value, + OutgoingTransactionMessageStatus::SUPPRESSED->value, + ], + ], + ]; + + if (!$showResolved) { + $where[] = ['resolved_at', '=', null]; + } + + return $this->paginateWhere( + where: $where, + limit: $perPage, + ); + } + + public function findAccountIdByRecipientEmail(string $email): ?int + { + $result = DB::table('outgoing_transaction_messages') + ->join('events', 'outgoing_transaction_messages.event_id', '=', 'events.id') + ->where('outgoing_transaction_messages.recipient', strtolower($email)) + ->orderByDesc('outgoing_transaction_messages.created_at') + ->select('events.account_id') + ->first(); + + return $result?->account_id; + } +} diff --git a/backend/app/Repository/Interfaces/EmailSuppressionRepositoryInterface.php b/backend/app/Repository/Interfaces/EmailSuppressionRepositoryInterface.php new file mode 100644 index 0000000000..7fb835f476 --- /dev/null +++ b/backend/app/Repository/Interfaces/EmailSuppressionRepositoryInterface.php @@ -0,0 +1,18 @@ + + */ +interface EmailSuppressionRepositoryInterface extends RepositoryInterface +{ + public function isEmailSuppressed(string $email, ?int $accountId, string $reason): bool; + + public function findByEmail(string $email, ?int $accountId = null): Collection; + + public function findOrCreateSuppression(array $uniqueAttributes, array $additionalValues): EmailSuppressionDomainObject; +} diff --git a/backend/app/Repository/Interfaces/OutgoingMessageRepositoryInterface.php b/backend/app/Repository/Interfaces/OutgoingMessageRepositoryInterface.php index 21155af660..8bf5cfc33d 100644 --- a/backend/app/Repository/Interfaces/OutgoingMessageRepositoryInterface.php +++ b/backend/app/Repository/Interfaces/OutgoingMessageRepositoryInterface.php @@ -3,11 +3,19 @@ namespace HiEvents\Repository\Interfaces; use HiEvents\DomainObjects\OutgoingMessageDomainObject; +use HiEvents\Http\DTO\QueryParamsDTO; +use Illuminate\Pagination\LengthAwarePaginator; /** * @extends RepositoryInterface */ interface OutgoingMessageRepositoryInterface extends RepositoryInterface { + public function findAccountIdByRecipientEmail(string $email): ?int; + public function markAsBounced(string $sesMessageId): bool; + + public function markAsDelivered(string $sesMessageId): bool; + + public function getForEvent(int $eventId, QueryParamsDTO $params): LengthAwarePaginator; } diff --git a/backend/app/Repository/Interfaces/OutgoingTransactionMessageRepositoryInterface.php b/backend/app/Repository/Interfaces/OutgoingTransactionMessageRepositoryInterface.php new file mode 100644 index 0000000000..6238d731e0 --- /dev/null +++ b/backend/app/Repository/Interfaces/OutgoingTransactionMessageRepositoryInterface.php @@ -0,0 +1,25 @@ + + */ +interface OutgoingTransactionMessageRepositoryInterface extends RepositoryInterface +{ + public function findBySesMessageId(string $sesMessageId): ?OutgoingTransactionMessageDomainObject; + + public function markAsBounced(int $id): void; + + public function markAsDelivered(int $id): void; + + public function getForEvent(int $eventId, QueryParamsDTO $params): LengthAwarePaginator; + + public function getFailuresForEvent(int $eventId, int $perPage = 20, bool $showResolved = false): LengthAwarePaginator; + + public function findAccountIdByRecipientEmail(string $email): ?int; +} diff --git a/backend/app/Resources/DeliveryIssue/DeliveryIssueResource.php b/backend/app/Resources/DeliveryIssue/DeliveryIssueResource.php new file mode 100644 index 0000000000..f85bfbbc81 --- /dev/null +++ b/backend/app/Resources/DeliveryIssue/DeliveryIssueResource.php @@ -0,0 +1,24 @@ + $this->id, + 'source_type' => $this->source_type, + 'email_type' => $this->email_type, + 'status' => $this->status, + 'subject' => $this->subject, + 'recipient' => $this->recipient, + 'updated_at' => $this->updated_at, + 'resolved_at' => $this->resolved_at, + 'retry_for_id' => $this->retry_for_id ?? null, + ]; + } +} diff --git a/backend/app/Resources/Message/OutgoingMessageResource.php b/backend/app/Resources/Message/OutgoingMessageResource.php index 4bf92638da..534f7612c8 100644 --- a/backend/app/Resources/Message/OutgoingMessageResource.php +++ b/backend/app/Resources/Message/OutgoingMessageResource.php @@ -19,7 +19,16 @@ public function toArray(Request $request): array 'recipient' => $this->getRecipient(), 'status' => $this->getStatus(), 'subject' => $this->getSubject(), + 'resolved_at' => $this->getResolvedAt(), + 'resolution_type' => $this->getResolutionType(), + 'retry_for_id' => $this->getRetryForId(), + 'retry_count' => $this->getRetryCount(), + 'latest_retry_recipient' => $this->getLatestRetryRecipient(), + 'latest_retry_status' => $this->getLatestRetryStatus(), + 'original_recipient' => $this->getOriginalRecipient(), + 'original_status' => $this->getOriginalStatus(), 'created_at' => $this->getCreatedAt(), + 'updated_at' => $this->getUpdatedAt(), ]; } } diff --git a/backend/app/Resources/TransactionMessage/OutgoingTransactionMessageResource.php b/backend/app/Resources/TransactionMessage/OutgoingTransactionMessageResource.php new file mode 100644 index 0000000000..edb794c682 --- /dev/null +++ b/backend/app/Resources/TransactionMessage/OutgoingTransactionMessageResource.php @@ -0,0 +1,37 @@ + $this->getId(), + 'event_id' => $this->getEventId(), + 'order_id' => $this->getOrderId(), + 'attendee_id' => $this->getAttendeeId(), + 'email_type' => $this->getEmailType(), + 'recipient' => $this->getRecipient(), + 'subject' => $this->getSubject(), + 'status' => $this->getStatus(), + 'resolved_at' => $this->getResolvedAt(), + 'resolution_type' => $this->getResolutionType(), + 'retry_for_id' => $this->getRetryForId(), + 'retry_count' => $this->getRetryCount(), + 'latest_retry_recipient' => $this->getLatestRetryRecipient(), + 'latest_retry_status' => $this->getLatestRetryStatus(), + 'original_recipient' => $this->getOriginalRecipient(), + 'original_status' => $this->getOriginalStatus(), + 'created_at' => $this->getCreatedAt(), + 'updated_at' => $this->getUpdatedAt(), + ]; + } +} diff --git a/backend/app/Services/Application/Handlers/Admin/DTO/GetAllEmailSuppressionsDTO.php b/backend/app/Services/Application/Handlers/Admin/DTO/GetAllEmailSuppressionsDTO.php new file mode 100644 index 0000000000..01bbf64161 --- /dev/null +++ b/backend/app/Services/Application/Handlers/Admin/DTO/GetAllEmailSuppressionsDTO.php @@ -0,0 +1,18 @@ +select([ + 'email_suppressions.*', + 'accounts.name as account_name', + ]) + ->leftJoin('accounts', 'accounts.id', '=', 'email_suppressions.account_id') + ->whereNull('email_suppressions.deleted_at'); + + if ($dto->search) { + $query->where('email_suppressions.email', 'ilike', '%' . $dto->search . '%'); + } + + if ($dto->reason) { + $query->where('email_suppressions.reason', $dto->reason); + } + + if ($dto->source) { + $query->where('email_suppressions.source', $dto->source); + } + + $sortColumn = in_array($dto->sortBy, self::ALLOWED_SORT_COLUMNS, true) ? $dto->sortBy : 'created_at'; + $sortDirection = in_array(strtolower($dto->sortDirection ?? 'desc'), ['asc', 'desc']) ? $dto->sortDirection : 'desc'; + + $query->orderBy('email_suppressions.' . $sortColumn, $sortDirection); + + return $query->paginate($dto->perPage); + } +} diff --git a/backend/app/Services/Application/Handlers/DeliveryIssue/GetDeliveryIssuesHandler.php b/backend/app/Services/Application/Handlers/DeliveryIssue/GetDeliveryIssuesHandler.php new file mode 100644 index 0000000000..83e81e1ec1 --- /dev/null +++ b/backend/app/Services/Application/Handlers/DeliveryIssue/GetDeliveryIssuesHandler.php @@ -0,0 +1,46 @@ +total; + + $offset = ($page - 1) * $perPage; + + $results = DB::select( + "{$sql} ORDER BY updated_at DESC LIMIT ? OFFSET ?", + [...$bindings, $perPage, $offset], + ); + + return new LengthAwarePaginator( + items: collect($results), + total: $total, + perPage: $perPage, + currentPage: $page, + ); + } +} diff --git a/backend/app/Services/Application/Handlers/Email/Ses/DTO/SesWebhookDTO.php b/backend/app/Services/Application/Handlers/Email/Ses/DTO/SesWebhookDTO.php new file mode 100644 index 0000000000..00c84247cd --- /dev/null +++ b/backend/app/Services/Application/Handlers/Email/Ses/DTO/SesWebhookDTO.php @@ -0,0 +1,14 @@ +payload, true, 512, JSON_THROW_ON_ERROR); + + $this->signatureVerificationService->verify($payload); + + // Handle SNS SubscriptionConfirmation + if (($payload['Type'] ?? null) === 'SubscriptionConfirmation') { + $this->handleSubscriptionConfirmation($payload); + return; + } + + // Only process Notification type + if (($payload['Type'] ?? null) !== 'Notification') { + $this->logger->debug('Received non-Notification SNS message type', [ + 'type' => $payload['Type'] ?? 'unknown', + ]); + return; + } + + // Validate TopicArn if configured + $expectedTopicArn = config('services.ses.sns_topic_arn'); + if ($expectedTopicArn && ($payload['TopicArn'] ?? null) !== $expectedTopicArn) { + $this->logger->warning('SNS TopicArn mismatch', [ + 'expected' => $expectedTopicArn, + 'received' => $payload['TopicArn'] ?? 'none', + ]); + return; + } + + // Deduplicate via SNS MessageId + $snsMessageId = $payload['MessageId'] ?? null; + if ($snsMessageId && $this->hasMessageBeenHandled($snsMessageId)) { + $this->logger->debug('SNS message already handled', [ + 'message_id' => $snsMessageId, + ]); + return; + } + + // Parse the inner SES message + $message = json_decode($payload['Message'] ?? '{}', true, 512, JSON_THROW_ON_ERROR); + + // SES Notifications use 'notificationType', SES Event Destinations use 'eventType' + $notificationType = $message['notificationType'] ?? $message['eventType'] ?? null; + + switch ($notificationType) { + case 'Bounce': + $this->bounceHandler->handle($message, $payload); + break; + case 'Complaint': + $this->complaintHandler->handle($message, $payload); + break; + case 'Delivery': + $this->deliveryHandler->handle($message, $payload); + break; + default: + $this->logger->debug('Unhandled SES notification type', [ + 'notification_type' => $notificationType, + ]); + break; + } + + if ($snsMessageId) { + $this->markMessageAsHandled($snsMessageId); + } + + } catch (SnsSignatureVerificationException $exception) { + $this->logger->warning('SNS signature verification failed', [ + 'error' => $exception->getMessage(), + ]); + throw $exception; + } catch (JsonException $exception) { + $this->logger->error('Invalid JSON in SES webhook payload', [ + 'payload' => $webhookDTO->payload, + 'error' => $exception->getMessage(), + ]); + throw $exception; + } catch (Throwable $exception) { + $this->logger->error('Unhandled SES webhook error: ' . $exception->getMessage(), [ + 'payload' => $webhookDTO->payload, + ]); + throw $exception; + } + } + + private function handleSubscriptionConfirmation(array $payload): void + { + $subscribeUrl = $payload['SubscribeURL'] ?? null; + + if (!$subscribeUrl) { + $this->logger->warning('SubscriptionConfirmation missing SubscribeURL'); + return; + } + + if (!$this->isValidSnsSubscribeUrl($subscribeUrl)) { + $this->logger->warning('SubscriptionConfirmation has invalid SubscribeURL', [ + 'subscribe_url' => $subscribeUrl, + ]); + return; + } + + $this->logger->info('Confirming SNS subscription', [ + 'topic_arn' => $payload['TopicArn'] ?? 'unknown', + ]); + + Http::get($subscribeUrl); + } + + private function isValidSnsSubscribeUrl(string $url): bool + { + $parsed = parse_url($url); + + if ($parsed === false || !isset($parsed['host'], $parsed['scheme'])) { + return false; + } + + if ($parsed['scheme'] !== 'https') { + return false; + } + + return (bool) preg_match('/^sns\.[a-z0-9-]+\.amazonaws\.com$/', strtolower($parsed['host'])); + } + + private function hasMessageBeenHandled(string $messageId): bool + { + return $this->cache->has('ses_sns_message_' . $messageId); + } + + private function markMessageAsHandled(string $messageId): void + { + $this->cache->put('ses_sns_message_' . $messageId, true, now()->addMinutes(60)); + } +} diff --git a/backend/app/Services/Application/Handlers/Message/ResendOutgoingMessageHandler.php b/backend/app/Services/Application/Handlers/Message/ResendOutgoingMessageHandler.php new file mode 100644 index 0000000000..22b03f9ab9 --- /dev/null +++ b/backend/app/Services/Application/Handlers/Message/ResendOutgoingMessageHandler.php @@ -0,0 +1,128 @@ +outgoingMessageRepository->findFirstWhere([ + OutgoingMessageDomainObjectAbstract::ID => $messageId, + OutgoingMessageDomainObjectAbstract::EVENT_ID => $eventId, + ]); + + if (!$outgoingMessage) { + throw ValidationException::withMessages(['message' => [__('Outgoing message not found')]]); + } + + $message = $this->messageRepository->findById($outgoingMessage->getMessageId()); + + $event = $this->eventRepository + ->loadRelation(new Relationship(EventSettingDomainObject::class)) + ->findById($eventId); + + $recipient = $newEmail ? strtolower($newEmail) : $outgoingMessage->getRecipient(); + + try { + $type = MessageTypeEnum::fromName($message->getType()); + } catch (\Throwable) { + $type = MessageTypeEnum::ALL_ATTENDEES; + } + + $messageDTO = new SendMessageDTO( + account_id: $event->getAccountId(), + event_id: $eventId, + subject: $message->getSubject(), + message: $message->getMessage(), + type: $type, + is_test: false, + send_copy_to_current_user: false, + sent_by_user_id: $message->getSentByUserId(), + id: $message->getId(), + ); + + $mail = new EventMessage($event, $event->getEventSettings(), $messageDTO); + + if ($outgoingMessage->getSesMessageId()) { + $mail->retryForSesMessageId = $outgoingMessage->getSesMessageId(); + } + + if ($this->suppressionService->isEmailSuppressed($recipient, $event->getAccountId(), 'marketing')) { + $this->outgoingMessageRepository->create([ + OutgoingMessageDomainObjectAbstract::MESSAGE_ID => $message->getId(), + OutgoingMessageDomainObjectAbstract::EVENT_ID => $eventId, + OutgoingMessageDomainObjectAbstract::STATUS => OutgoingMessageStatus::SUPPRESSED->name, + OutgoingMessageDomainObjectAbstract::RECIPIENT => $recipient, + OutgoingMessageDomainObjectAbstract::SUBJECT => $message->getSubject(), + OutgoingMessageDomainObjectAbstract::RETRY_FOR_ID => $outgoingMessage->getId(), + ]); + + return $this->outgoingMessageRepository->findById($messageId); + } + + try { + $sentMessage = $this->mailer->to($recipient)->sendNow($mail); + } catch (Throwable $e) { + $this->outgoingMessageRepository->create([ + OutgoingMessageDomainObjectAbstract::MESSAGE_ID => $message->getId(), + OutgoingMessageDomainObjectAbstract::EVENT_ID => $eventId, + OutgoingMessageDomainObjectAbstract::STATUS => OutgoingMessageStatus::FAILED->name, + OutgoingMessageDomainObjectAbstract::RECIPIENT => $recipient, + OutgoingMessageDomainObjectAbstract::SUBJECT => $message->getSubject(), + OutgoingMessageDomainObjectAbstract::RETRY_FOR_ID => $outgoingMessage->getId(), + ]); + + throw $e; + } + + $this->outgoingMessageRepository->create([ + OutgoingMessageDomainObjectAbstract::MESSAGE_ID => $message->getId(), + OutgoingMessageDomainObjectAbstract::EVENT_ID => $eventId, + OutgoingMessageDomainObjectAbstract::STATUS => OutgoingMessageStatus::SENT->name, + OutgoingMessageDomainObjectAbstract::RECIPIENT => $recipient, + OutgoingMessageDomainObjectAbstract::SUBJECT => $message->getSubject(), + OutgoingMessageDomainObjectAbstract::SES_MESSAGE_ID => $sentMessage?->getMessageId(), + OutgoingMessageDomainObjectAbstract::RETRY_FOR_ID => $outgoingMessage->getId(), + ]); + + $this->outgoingMessageRepository->updateWhere( + attributes: [ + OutgoingMessageDomainObjectAbstract::RESOLVED_AT => now()->toDateTimeString(), + OutgoingMessageDomainObjectAbstract::RESOLUTION_TYPE => 'auto', + ], + where: [OutgoingMessageDomainObjectAbstract::ID => $outgoingMessage->getId()], + ); + + return $this->outgoingMessageRepository->findById($messageId); + } +} diff --git a/backend/app/Services/Application/Handlers/TransactionMessage/ResendTransactionMessageHandler.php b/backend/app/Services/Application/Handlers/TransactionMessage/ResendTransactionMessageHandler.php new file mode 100644 index 0000000000..dd99ca0b7a --- /dev/null +++ b/backend/app/Services/Application/Handlers/TransactionMessage/ResendTransactionMessageHandler.php @@ -0,0 +1,185 @@ +repository->findFirstWhere([ + OutgoingTransactionMessageDomainObjectAbstract::ID => $messageId, + OutgoingTransactionMessageDomainObjectAbstract::EVENT_ID => $eventId, + ]); + + if (!$message) { + throw ValidationException::withMessages(['message' => [__('Transaction message not found')]]); + } + + $emailType = TransactionalEmailType::from($message->getEmailType()); + + if ($newEmail) { + $this->updateEntityEmail($message, $emailType, $newEmail); + } + + $this->resendByType($message, $emailType, $message->getSesMessageId(), $message->getId()); + + return $this->repository->findById($messageId); + } + + private function updateEntityEmail( + OutgoingTransactionMessageDomainObject $message, + TransactionalEmailType $emailType, + string $newEmail, + ): void + { + match ($emailType) { + TransactionalEmailType::ORDER_SUMMARY, + TransactionalEmailType::ORDER_FAILED => $this->updateOrderEmail($message->getOrderId(), $newEmail), + + TransactionalEmailType::ATTENDEE_TICKET, + TransactionalEmailType::WAITLIST_OFFER, + TransactionalEmailType::WAITLIST_CONFIRMATION, + TransactionalEmailType::WAITLIST_OFFER_EXPIRED => $this->updateAttendeeEmail($message->getAttendeeId(), $newEmail), + }; + } + + private function updateOrderEmail(int $orderId, string $email): void + { + $this->orderRepository->updateWhere( + attributes: ['email' => strtolower($email)], + where: ['id' => $orderId], + ); + } + + private function updateAttendeeEmail(int $attendeeId, string $email): void + { + $this->attendeeRepository->updateWhere( + attributes: ['email' => strtolower($email)], + where: ['id' => $attendeeId], + ); + } + + private function resendByType( + OutgoingTransactionMessageDomainObject $message, + TransactionalEmailType $emailType, + ?string $retryForSesMessageId = null, + ?int $retryForId = null, + ): void + { + $event = $this->eventRepository + ->loadRelation(new Relationship(OrganizerDomainObject::class, name: 'organizer')) + ->loadRelation(new Relationship(EventSettingDomainObject::class)) + ->findById($message->getEventId()); + + match ($emailType) { + TransactionalEmailType::ORDER_SUMMARY => $this->resendOrderSummary($message, $event, $retryForSesMessageId, $retryForId), + TransactionalEmailType::ORDER_FAILED => $this->resendOrderFailed($message, $retryForSesMessageId, $retryForId), + TransactionalEmailType::ATTENDEE_TICKET => $this->resendAttendeeTicket($message, $event, $retryForSesMessageId, $retryForId), + TransactionalEmailType::WAITLIST_OFFER, + TransactionalEmailType::WAITLIST_CONFIRMATION, + TransactionalEmailType::WAITLIST_OFFER_EXPIRED => $this->resendWaitlistEmail($emailType, $message), + }; + } + + private function resendOrderSummary(OutgoingTransactionMessageDomainObject $message, $event, ?string $retryForSesMessageId = null, ?int $retryForId = null): void + { + $order = $this->orderRepository + ->loadRelation(OrderItemDomainObject::class) + ->loadRelation(InvoiceDomainObject::class) + ->findById($message->getOrderId()); + + $this->sendOrderDetailsService->sendCustomerOrderSummary( + order: $order, + event: $event, + organizer: $event->getOrganizer(), + eventSettings: $event->getEventSettings(), + invoice: $order->getLatestInvoice(), + retryForSesMessageId: $retryForSesMessageId, + retryForId: $retryForId, + ); + } + + private function resendOrderFailed(OutgoingTransactionMessageDomainObject $message, ?string $retryForSesMessageId = null, ?int $retryForId = null): void + { + $order = $this->orderRepository + ->loadRelation(OrderItemDomainObject::class) + ->loadRelation(InvoiceDomainObject::class) + ->findById($message->getOrderId()); + + $this->sendOrderDetailsService->sendOrderSummaryAndTicketEmails($order, retryForSesMessageId: $retryForSesMessageId, retryForId: $retryForId); + } + + private function resendAttendeeTicket(OutgoingTransactionMessageDomainObject $message, $event, ?string $retryForSesMessageId = null, ?int $retryForId = null): void + { + $order = $this->orderRepository->findById($message->getOrderId()); + + $attendee = $this->attendeeRepository->findById($message->getAttendeeId()); + + $this->sendAttendeeTicketService->send( + order: $order, + attendee: $attendee, + event: $event, + eventSettings: $event->getEventSettings(), + organizer: $event->getOrganizer(), + retryForSesMessageId: $retryForSesMessageId, + retryForId: $retryForId, + ); + } + + private function resendWaitlistEmail( + TransactionalEmailType $emailType, + OutgoingTransactionMessageDomainObject $message, + ): void + { + $jobClass = match ($emailType) { + TransactionalEmailType::WAITLIST_OFFER => \HiEvents\Jobs\Waitlist\SendWaitlistOfferEmailJob::class, + TransactionalEmailType::WAITLIST_CONFIRMATION => \HiEvents\Jobs\Waitlist\SendWaitlistConfirmationEmailJob::class, + TransactionalEmailType::WAITLIST_OFFER_EXPIRED => \HiEvents\Jobs\Waitlist\SendWaitlistOfferExpiredEmailJob::class, + default => null, + }; + + if (!$jobClass || !$message->getAttendeeId()) { + return; + } + + // Waitlist jobs need the WaitlistEntryDomainObject — look up by attendee + $waitlistEntry = app(\HiEvents\Repository\Interfaces\WaitlistEntryRepositoryInterface::class) + ->findFirstWhere(['attendee_id' => $message->getAttendeeId()]); + + if ($waitlistEntry) { + dispatch(new $jobClass($waitlistEntry)); + } + } +} diff --git a/backend/app/Services/Domain/Attendee/SendAttendeeTicketService.php b/backend/app/Services/Domain/Attendee/SendAttendeeTicketService.php index 7dceaaef96..f409d63460 100644 --- a/backend/app/Services/Domain/Attendee/SendAttendeeTicketService.php +++ b/backend/app/Services/Domain/Attendee/SendAttendeeTicketService.php @@ -3,18 +3,21 @@ namespace HiEvents\Services\Domain\Attendee; use HiEvents\DomainObjects\AttendeeDomainObject; +use HiEvents\DomainObjects\Enums\TransactionalEmailType; use HiEvents\DomainObjects\EventDomainObject; use HiEvents\DomainObjects\EventSettingDomainObject; use HiEvents\DomainObjects\OrderDomainObject; use HiEvents\DomainObjects\OrganizerDomainObject; use HiEvents\Services\Domain\Email\MailBuilderService; +use HiEvents\Services\Domain\Email\TransactionalEmailTrackingService; use Illuminate\Contracts\Mail\Mailer; class SendAttendeeTicketService { public function __construct( - private readonly Mailer $mailer, - private readonly MailBuilderService $mailBuilderService, + private readonly Mailer $mailer, + private readonly MailBuilderService $mailBuilderService, + private readonly TransactionalEmailTrackingService $trackingService, ) { } @@ -25,6 +28,8 @@ public function send( EventDomainObject $event, EventSettingDomainObject $eventSettings, OrganizerDomainObject $organizer, + ?string $retryForSesMessageId = null, + ?int $retryForId = null, ): void { $mail = $this->mailBuilderService->buildAttendeeTicketMail( @@ -35,9 +40,19 @@ public function send( $organizer ); - $this->mailer - ->to($attendee->getEmail()) - ->locale($attendee->getLocale()) - ->send($mail); + $this->trackingService->recordAndSend( + mailer: $this->mailer, + recipient: $attendee->getEmail(), + mail: $mail, + emailType: TransactionalEmailType::ATTENDEE_TICKET, + subject: $mail->envelope()->subject, + eventId: $event->getId(), + orderId: $order->getId(), + attendeeId: $attendee->getId(), + accountId: $event->getAccountId(), + locale: $attendee->getLocale(), + retryForSesMessageId: $retryForSesMessageId, + retryForId: $retryForId, + ); } } diff --git a/backend/app/Services/Domain/Email/EmailSuppressionService.php b/backend/app/Services/Domain/Email/EmailSuppressionService.php new file mode 100644 index 0000000000..f9eb727bf4 --- /dev/null +++ b/backend/app/Services/Domain/Email/EmailSuppressionService.php @@ -0,0 +1,118 @@ +emailSuppressionRepository->findByEmail($email, $accountId); + + if ($suppressions->isEmpty()) { + return false; + } + + foreach ($suppressions as $suppression) { + /** @var EmailSuppressionDomainObject $suppression */ + if ($suppression->getReason() === EmailSuppressionReasonEnum::BOUNCE->value) { + if ($suppression->getBounceType() === 'Permanent') { + // Permanent bounce: suppress for ALL email types + return true; + } + + if ($emailType === 'marketing') { + // Transient/Undetermined bounce: suppress only marketing + return true; + } + } + + if ($suppression->getReason() === EmailSuppressionReasonEnum::COMPLAINT->value) { + if ($emailType === 'marketing') { + // Complaint: suppress only marketing + return true; + } + // Transactional emails to complaint addresses still send + } + } + + return false; + } + + public function suppressEmail( + string $email, + string $reason, + string $source, + ?int $accountId = null, + ?string $bounceType = null, + ?string $bounceSubType = null, + ?string $complaintType = null, + ?string $snsMessageId = null, + mixed $rawPayload = null, + ): EmailSuppressionDomainObject + { + return $this->emailSuppressionRepository->findOrCreateSuppression( + uniqueAttributes: [ + EmailSuppressionDomainObjectAbstract::EMAIL => strtolower($email), + EmailSuppressionDomainObjectAbstract::ACCOUNT_ID => $accountId, + EmailSuppressionDomainObjectAbstract::REASON => $reason, + ], + additionalValues: [ + EmailSuppressionDomainObjectAbstract::BOUNCE_TYPE => $bounceType, + EmailSuppressionDomainObjectAbstract::BOUNCE_SUB_TYPE => $bounceSubType, + EmailSuppressionDomainObjectAbstract::COMPLAINT_TYPE => $complaintType, + EmailSuppressionDomainObjectAbstract::SOURCE => $source, + EmailSuppressionDomainObjectAbstract::SNS_MESSAGE_ID => $snsMessageId, + EmailSuppressionDomainObjectAbstract::RAW_PAYLOAD => $rawPayload ? json_encode($rawPayload) : null, + ], + ); + } + + public function removeSuppressionById(int $id): void + { + $this->emailSuppressionRepository->deleteById($id); + } + + public function removeSuppression(string $email, ?int $accountId = null, ?string $reason = null): void + { + $where = [ + 'email' => strtolower($email), + ]; + + if ($accountId !== null) { + $where['account_id'] = $accountId; + } + + if ($reason !== null) { + $where['reason'] = $reason; + } + + $suppressions = $this->emailSuppressionRepository->findWhere($where); + + foreach ($suppressions as $suppression) { + $this->emailSuppressionRepository->deleteById($suppression->getId()); + } + } +} diff --git a/backend/app/Services/Domain/Email/Ses/EventHandlers/BounceHandler.php b/backend/app/Services/Domain/Email/Ses/EventHandlers/BounceHandler.php new file mode 100644 index 0000000000..11cdbfc43f --- /dev/null +++ b/backend/app/Services/Domain/Email/Ses/EventHandlers/BounceHandler.php @@ -0,0 +1,93 @@ +outgoingMessageRepository->findAccountIdByRecipientEmail($email) + ?? $this->outgoingTransactionMessageRepository->findAccountIdByRecipientEmail($email); + + $this->logger->info('Processing SES bounce', [ + 'email' => $email, + 'bounce_type' => $bounceType, + 'bounce_sub_type' => $bounceSubType, + 'account_id' => $accountId, + 'ses_message_id' => $sesMessageId, + ]); + + $this->emailSuppressionService->suppressEmail( + email: $email, + reason: EmailSuppressionReasonEnum::BOUNCE->value, + source: EmailSuppressionSourceEnum::SES_NOTIFICATION->value, + accountId: $accountId, + bounceType: $bounceType, + bounceSubType: $bounceSubType, + snsMessageId: $snsMessageId, + rawPayload: $snsPayload, + ); + + if ($sesMessageId) { + $this->markOutgoingMessagesAsBounced($sesMessageId); + } + } + + $retryForSesMessageId = $this->getRetryForSesMessageId($message); + if ($retryForSesMessageId) { + $this->logger->info('Retry email bounced — original remains unresolved', [ + 'retry_ses_message_id' => $sesMessageId, + 'original_ses_message_id' => $retryForSesMessageId, + ]); + } + } + + private function markOutgoingMessagesAsBounced(string $sesMessageId): void + { + if ($this->outgoingMessageRepository->markAsBounced($sesMessageId)) { + $this->logger->info('Marked outgoing message as bounced', ['ses_message_id' => $sesMessageId]); + } + + $transactionMessage = $this->outgoingTransactionMessageRepository->findBySesMessageId($sesMessageId); + + if ($transactionMessage) { + $this->outgoingTransactionMessageRepository->markAsBounced($transactionMessage->getId()); + $this->logger->info('Marked transaction message as bounced', [ + 'ses_message_id' => $sesMessageId, + 'transaction_message_id' => $transactionMessage->getId(), + ]); + } + } +} diff --git a/backend/app/Services/Domain/Email/Ses/EventHandlers/ComplaintHandler.php b/backend/app/Services/Domain/Email/Ses/EventHandlers/ComplaintHandler.php new file mode 100644 index 0000000000..cef3b6760c --- /dev/null +++ b/backend/app/Services/Domain/Email/Ses/EventHandlers/ComplaintHandler.php @@ -0,0 +1,80 @@ +outgoingMessageRepository->findAccountIdByRecipientEmail($email) + ?? $this->outgoingTransactionMessageRepository->findAccountIdByRecipientEmail($email); + + $this->logger->info('Processing SES complaint', [ + 'email' => $email, + 'complaint_type' => $complaintType, + 'account_id' => $accountId, + 'ses_message_id' => $sesMessageId, + ]); + + $this->emailSuppressionService->suppressEmail( + email: $email, + reason: EmailSuppressionReasonEnum::COMPLAINT->value, + source: EmailSuppressionSourceEnum::SES_NOTIFICATION->value, + accountId: $accountId, + complaintType: $complaintType, + snsMessageId: $snsMessageId, + rawPayload: $snsPayload, + ); + + if ($sesMessageId) { + $this->markOutgoingMessagesAsBounced($sesMessageId); + } + } + } + + private function markOutgoingMessagesAsBounced(string $sesMessageId): void + { + if ($this->outgoingMessageRepository->markAsBounced($sesMessageId)) { + $this->logger->info('Marked outgoing message as bounced', ['ses_message_id' => $sesMessageId]); + } + + $transactionMessage = $this->outgoingTransactionMessageRepository->findBySesMessageId($sesMessageId); + + if ($transactionMessage) { + $this->outgoingTransactionMessageRepository->markAsBounced($transactionMessage->getId()); + $this->logger->info('Marked transaction message as bounced', [ + 'ses_message_id' => $sesMessageId, + 'transaction_message_id' => $transactionMessage->getId(), + ]); + } + } +} diff --git a/backend/app/Services/Domain/Email/Ses/EventHandlers/DeliveryHandler.php b/backend/app/Services/Domain/Email/Ses/EventHandlers/DeliveryHandler.php new file mode 100644 index 0000000000..ab693e2878 --- /dev/null +++ b/backend/app/Services/Domain/Email/Ses/EventHandlers/DeliveryHandler.php @@ -0,0 +1,105 @@ +logger->debug('Delivery notification missing mail.messageId'); + return; + } + + $recipients = $message['delivery']['recipients'] ?? []; + + $this->logger->info('Processing SES delivery', [ + 'recipients' => $recipients, + 'ses_message_id' => $sesMessageId, + ]); + + $this->markOutgoingMessagesAsDelivered($sesMessageId); + + $retryForSesMessageId = $this->getRetryForSesMessageId($message); + if ($retryForSesMessageId) { + $this->autoResolveOriginal($retryForSesMessageId); + } + } + + private function markOutgoingMessagesAsDelivered(string $sesMessageId): void + { + if ($this->outgoingMessageRepository->markAsDelivered($sesMessageId)) { + $this->logger->info('Marked outgoing message as delivered', ['ses_message_id' => $sesMessageId]); + } + + $transactionMessage = $this->outgoingTransactionMessageRepository->findBySesMessageId($sesMessageId); + + if ($transactionMessage) { + $this->outgoingTransactionMessageRepository->markAsDelivered($transactionMessage->getId()); + $this->logger->info('Marked transaction message as delivered', [ + 'ses_message_id' => $sesMessageId, + 'transaction_message_id' => $transactionMessage->getId(), + ]); + } + } + + private function autoResolveOriginal(string $originalSesMessageId): void + { + $resolvedAt = now()->toDateTimeString(); + + $transactionMessage = $this->outgoingTransactionMessageRepository->findFirstWhere([ + OutgoingTransactionMessageDomainObjectAbstract::SES_MESSAGE_ID => $originalSesMessageId, + ]); + + if ($transactionMessage && !$transactionMessage->getResolvedAt()) { + $this->outgoingTransactionMessageRepository->updateWhere( + attributes: [ + OutgoingTransactionMessageDomainObjectAbstract::RESOLVED_AT => $resolvedAt, + OutgoingTransactionMessageDomainObjectAbstract::RESOLUTION_TYPE => 'auto', + ], + where: [OutgoingTransactionMessageDomainObjectAbstract::ID => $transactionMessage->getId()], + ); + $this->logger->info('Auto-resolved original transaction message', [ + 'original_ses_message_id' => $originalSesMessageId, + 'original_id' => $transactionMessage->getId(), + ]); + return; + } + + $outgoingMessage = $this->outgoingMessageRepository->findFirstWhere([ + OutgoingMessageDomainObjectAbstract::SES_MESSAGE_ID => $originalSesMessageId, + ]); + + if ($outgoingMessage && !$outgoingMessage->getResolvedAt()) { + $this->outgoingMessageRepository->updateWhere( + attributes: [ + OutgoingMessageDomainObjectAbstract::RESOLVED_AT => $resolvedAt, + OutgoingMessageDomainObjectAbstract::RESOLUTION_TYPE => 'auto', + ], + where: [OutgoingMessageDomainObjectAbstract::ID => $outgoingMessage->getId()], + ); + $this->logger->info('Auto-resolved original outgoing message', [ + 'original_ses_message_id' => $originalSesMessageId, + 'original_id' => $outgoingMessage->getId(), + ]); + } + } +} diff --git a/backend/app/Services/Domain/Email/Ses/EventHandlers/ExtractsRetryHeader.php b/backend/app/Services/Domain/Email/Ses/EventHandlers/ExtractsRetryHeader.php new file mode 100644 index 0000000000..d48cab20c7 --- /dev/null +++ b/backend/app/Services/Domain/Email/Ses/EventHandlers/ExtractsRetryHeader.php @@ -0,0 +1,19 @@ +suppressionService->isEmailSuppressed($recipient, $accountId, 'transactional')) { + $this->recordMessage($recipient, $subject, $emailType, OutgoingTransactionMessageStatus::SUPPRESSED, $eventId, $orderId, $attendeeId, retryForId: $retryForId); + return; + } + + if ($retryForSesMessageId && method_exists($mail, '__set')) { + $mail->retryForSesMessageId = $retryForSesMessageId; + } elseif ($retryForSesMessageId && property_exists($mail, 'retryForSesMessageId')) { + $mail->retryForSesMessageId = $retryForSesMessageId; + } + + try { + $pendingMail = $mailer->to($recipient); + if ($locale) { + $pendingMail->locale($locale); + } + $sentMessage = $pendingMail->sendNow($mail); + } catch (Throwable $e) { + $this->recordMessage($recipient, $subject, $emailType, OutgoingTransactionMessageStatus::FAILED, $eventId, $orderId, $attendeeId, retryForId: $retryForId); + throw $e; + } + + $this->recordMessage($recipient, $subject, $emailType, OutgoingTransactionMessageStatus::SENT, $eventId, $orderId, $attendeeId, $sentMessage?->getMessageId(), $retryForId); + + if ($retryForId !== null) { + $this->repository->updateWhere( + attributes: [ + OutgoingTransactionMessageDomainObjectAbstract::RESOLVED_AT => now()->toDateTimeString(), + OutgoingTransactionMessageDomainObjectAbstract::RESOLUTION_TYPE => 'auto', + ], + where: [OutgoingTransactionMessageDomainObjectAbstract::ID => $retryForId], + ); + } + } + + private function recordMessage( + string $recipient, + string $subject, + TransactionalEmailType $emailType, + OutgoingTransactionMessageStatus $status, + ?int $eventId, + ?int $orderId, + ?int $attendeeId, + ?string $sesMessageId = null, + ?int $retryForId = null, + ): void + { + $attributes = [ + OutgoingTransactionMessageDomainObjectAbstract::EVENT_ID => $eventId, + OutgoingTransactionMessageDomainObjectAbstract::ORDER_ID => $orderId, + OutgoingTransactionMessageDomainObjectAbstract::ATTENDEE_ID => $attendeeId, + OutgoingTransactionMessageDomainObjectAbstract::EMAIL_TYPE => $emailType->value, + OutgoingTransactionMessageDomainObjectAbstract::RECIPIENT => strtolower($recipient), + OutgoingTransactionMessageDomainObjectAbstract::SUBJECT => $subject, + OutgoingTransactionMessageDomainObjectAbstract::STATUS => $status->value, + OutgoingTransactionMessageDomainObjectAbstract::SES_MESSAGE_ID => $sesMessageId, + ]; + + if ($retryForId !== null) { + $attributes[OutgoingTransactionMessageDomainObjectAbstract::RETRY_FOR_ID] = $retryForId; + } + + $this->repository->create($attributes); + } +} diff --git a/backend/app/Services/Domain/Mail/SendEventEmailMessagesService.php b/backend/app/Services/Domain/Mail/SendEventEmailMessagesService.php index 833ac9b164..d5530815b7 100644 --- a/backend/app/Services/Domain/Mail/SendEventEmailMessagesService.php +++ b/backend/app/Services/Domain/Mail/SendEventEmailMessagesService.php @@ -14,12 +14,16 @@ use HiEvents\Jobs\Event\SendEventEmailJob; use HiEvents\Mail\Event\EventMessage; use HiEvents\Repository\Eloquent\Value\Relationship; +use HiEvents\DomainObjects\Generated\OutgoingMessageDomainObjectAbstract; +use HiEvents\DomainObjects\Status\OutgoingMessageStatus; use HiEvents\Repository\Interfaces\AttendeeRepositoryInterface; use HiEvents\Repository\Interfaces\EventRepositoryInterface; use HiEvents\Repository\Interfaces\MessageRepositoryInterface; use HiEvents\Repository\Interfaces\OrderRepositoryInterface; +use HiEvents\Repository\Interfaces\OutgoingMessageRepositoryInterface; use HiEvents\Repository\Interfaces\UserRepositoryInterface; use HiEvents\Services\Application\Handlers\Message\DTO\SendMessageDTO; +use HiEvents\Services\Domain\Email\EmailSuppressionService; use Illuminate\Contracts\Bus\Dispatcher; use Illuminate\Support\Collection; use Symfony\Component\HttpKernel\Log\Logger; @@ -35,7 +39,9 @@ public function __construct( private readonly MessageRepositoryInterface $messageRepository, private readonly UserRepositoryInterface $userRepository, private readonly Logger $logger, - private readonly Dispatcher $dispatcher, + private readonly Dispatcher $dispatcher, + private readonly EmailSuppressionService $emailSuppressionService, + private readonly OutgoingMessageRepositoryInterface $outgoingMessageRepository, ) { } @@ -246,6 +252,24 @@ private function sendMessage( return; } + if ($this->emailSuppressionService->isEmailSuppressed($emailAddress, $messageData->account_id, 'marketing')) { + $this->logger->info('Email suppressed, skipping dispatch', [ + 'email' => $emailAddress, + 'message_id' => $messageData->id, + ]); + + $this->outgoingMessageRepository->create([ + OutgoingMessageDomainObjectAbstract::MESSAGE_ID => $messageData->id, + OutgoingMessageDomainObjectAbstract::EVENT_ID => $messageData->event_id, + OutgoingMessageDomainObjectAbstract::STATUS => OutgoingMessageStatus::SUPPRESSED->name, + OutgoingMessageDomainObjectAbstract::RECIPIENT => $emailAddress, + OutgoingMessageDomainObjectAbstract::SUBJECT => $messageData->subject, + ]); + + $this->sentEmails[] = $emailAddress; + return; + } + $this->dispatcher->dispatch( new SendEventEmailJob( email: $emailAddress, diff --git a/backend/app/Services/Domain/Mail/SendOrderDetailsService.php b/backend/app/Services/Domain/Mail/SendOrderDetailsService.php index a1b139d650..26898db5aa 100644 --- a/backend/app/Services/Domain/Mail/SendOrderDetailsService.php +++ b/backend/app/Services/Domain/Mail/SendOrderDetailsService.php @@ -3,6 +3,7 @@ namespace HiEvents\Services\Domain\Mail; use HiEvents\DomainObjects\AttendeeDomainObject; +use HiEvents\DomainObjects\Enums\TransactionalEmailType; use HiEvents\DomainObjects\EventDomainObject; use HiEvents\DomainObjects\EventSettingDomainObject; use HiEvents\DomainObjects\InvoiceDomainObject; @@ -17,21 +18,23 @@ use HiEvents\Repository\Interfaces\OrderRepositoryInterface; use HiEvents\Services\Domain\Attendee\SendAttendeeTicketService; use HiEvents\Services\Domain\Email\MailBuilderService; +use HiEvents\Services\Domain\Email\TransactionalEmailTrackingService; use Illuminate\Mail\Mailer; class SendOrderDetailsService { public function __construct( - private readonly EventRepositoryInterface $eventRepository, - private readonly OrderRepositoryInterface $orderRepository, - private readonly Mailer $mailer, - private readonly SendAttendeeTicketService $sendAttendeeTicketService, - private readonly MailBuilderService $mailBuilderService, + private readonly EventRepositoryInterface $eventRepository, + private readonly OrderRepositoryInterface $orderRepository, + private readonly Mailer $mailer, + private readonly SendAttendeeTicketService $sendAttendeeTicketService, + private readonly MailBuilderService $mailBuilderService, + private readonly TransactionalEmailTrackingService $trackingService, ) { } - public function sendOrderSummaryAndTicketEmails(OrderDomainObject $order): void + public function sendOrderSummaryAndTicketEmails(OrderDomainObject $order, ?string $retryForSesMessageId = null, ?int $retryForId = null): void { $order = $this->orderRepository ->loadRelation(OrderItemDomainObject::class) @@ -50,15 +53,26 @@ public function sendOrderSummaryAndTicketEmails(OrderDomainObject $order): void } if ($order->isOrderFailed()) { - $this->mailer - ->to($order->getEmail()) - ->locale($order->getLocale()) - ->send(new OrderFailed( - order: $order, - event: $event, - organizer: $event->getOrganizer(), - eventSettings: $event->getEventSettings(), - )); + $mail = new OrderFailed( + order: $order, + event: $event, + organizer: $event->getOrganizer(), + eventSettings: $event->getEventSettings(), + ); + + $this->trackingService->recordAndSend( + mailer: $this->mailer, + recipient: $order->getEmail(), + mail: $mail, + emailType: TransactionalEmailType::ORDER_FAILED, + subject: $mail->envelope()->subject, + eventId: $event->getId(), + orderId: $order->getId(), + accountId: $event->getAccountId(), + locale: $order->getLocale(), + retryForSesMessageId: $retryForSesMessageId, + retryForId: $retryForId, + ); } } @@ -67,7 +81,9 @@ public function sendCustomerOrderSummary( EventDomainObject $event, OrganizerDomainObject $organizer, EventSettingDomainObject $eventSettings, - ?InvoiceDomainObject $invoice = null + ?InvoiceDomainObject $invoice = null, + ?string $retryForSesMessageId = null, + ?int $retryForId = null, ): void { $mail = $this->mailBuilderService->buildOrderSummaryMail( @@ -78,10 +94,19 @@ public function sendCustomerOrderSummary( $invoice ); - $this->mailer - ->to($order->getEmail()) - ->locale($order->getLocale()) - ->send($mail); + $this->trackingService->recordAndSend( + mailer: $this->mailer, + recipient: $order->getEmail(), + mail: $mail, + emailType: TransactionalEmailType::ORDER_SUMMARY, + subject: $mail->envelope()->subject, + eventId: $event->getId(), + orderId: $order->getId(), + accountId: $event->getAccountId(), + locale: $order->getLocale(), + retryForSesMessageId: $retryForSesMessageId, + retryForId: $retryForId, + ); } private function sendAttendeeTicketEmails(OrderDomainObject $order, EventDomainObject $event): void diff --git a/backend/app/Services/Infrastructure/Aws/SnsSignatureVerificationService.php b/backend/app/Services/Infrastructure/Aws/SnsSignatureVerificationService.php new file mode 100644 index 0000000000..1a611365f8 --- /dev/null +++ b/backend/app/Services/Infrastructure/Aws/SnsSignatureVerificationService.php @@ -0,0 +1,112 @@ +isValidSigningCertUrl($signingCertUrl)) { + throw new SnsSignatureVerificationException( + __('Invalid SNS SigningCertURL: :url', ['url' => $signingCertUrl]) + ); + } + + $certificate = $this->fetchCertificate($signingCertUrl); + $stringToSign = $this->buildStringToSign($payload); + + $decodedSignature = base64_decode($signature, true); + if ($decodedSignature === false) { + throw new SnsSignatureVerificationException( + __('Invalid base64 signature in SNS message') + ); + } + + $publicKey = openssl_pkey_get_public($certificate); + if ($publicKey === false) { + throw new SnsSignatureVerificationException( + __('Unable to extract public key from SNS signing certificate') + ); + } + + $result = openssl_verify($stringToSign, $decodedSignature, $publicKey, OPENSSL_ALGO_SHA1); + + if ($result !== 1) { + throw new SnsSignatureVerificationException( + __('SNS message signature verification failed') + ); + } + } + + private function isValidSigningCertUrl(string $url): bool + { + return (bool) preg_match(self::SIGNING_CERT_URL_PATTERN, $url); + } + + private function fetchCertificate(string $url): string + { + $response = Http::timeout(5)->get($url); + + if (!$response->successful()) { + throw new SnsSignatureVerificationException( + __('Failed to fetch SNS signing certificate from :url', ['url' => $url]) + ); + } + + return $response->body(); + } + + private function buildStringToSign(array $payload): string + { + $type = $payload['Type'] ?? ''; + $keys = $type === 'Notification' + ? self::NOTIFICATION_STRING_TO_SIGN_KEYS + : self::SUBSCRIPTION_STRING_TO_SIGN_KEYS; + + $stringToSign = ''; + foreach ($keys as $key) { + if (array_key_exists($key, $payload)) { + $stringToSign .= $key . "\n" . $payload[$key] . "\n"; + } + } + + return $stringToSign; + } +} diff --git a/backend/config/services.php b/backend/config/services.php index 44f123a1e7..343511ca7a 100644 --- a/backend/config/services.php +++ b/backend/config/services.php @@ -29,6 +29,10 @@ 'key' => env('AWS_ACCESS_KEY_ID'), 'secret' => env('AWS_SECRET_ACCESS_KEY'), 'region' => env('AWS_DEFAULT_REGION', 'us-east-1'), + 'sns_topic_arn' => env('AWS_SNS_TOPIC_ARN'), + 'sns_verify_signature' => env('AWS_SNS_VERIFY_SIGNATURE', true), + 'suppression_enabled' => env('SES_SUPPRESSION_ENABLED', false), + 'configuration_set' => env('SES_CONFIGURATION_SET'), ], 'stripe' => [ diff --git a/backend/database/migrations/2026_03_09_000000_create_email_suppressions_table.php b/backend/database/migrations/2026_03_09_000000_create_email_suppressions_table.php new file mode 100644 index 0000000000..c5f85aa18b --- /dev/null +++ b/backend/database/migrations/2026_03_09_000000_create_email_suppressions_table.php @@ -0,0 +1,39 @@ +id(); + $table->unsignedBigInteger('account_id')->nullable(); + $table->string('email', 255); + $table->string('reason'); // bounce, complaint + $table->string('bounce_type')->nullable(); // Permanent, Transient, Undetermined + $table->string('bounce_sub_type')->nullable(); // General, NoEmail, Suppressed + $table->string('complaint_type')->nullable(); // abuse, not-spam, etc. + $table->string('source'); // ses_notification, manual + $table->string('sns_message_id')->nullable(); + $table->json('raw_payload')->nullable(); + $table->timestamps(); + $table->softDeletes(); + + $table->foreign('account_id') + ->references('id') + ->on('accounts') + ->nullOnDelete(); + + $table->unique(['email', 'account_id', 'reason', 'deleted_at'], 'email_suppressions_unique'); + $table->index(['email', 'deleted_at'], 'email_suppressions_email_deleted'); + $table->index('account_id', 'email_suppressions_account_id'); + }); + } + + public function down(): void + { + Schema::dropIfExists('email_suppressions'); + } +}; diff --git a/backend/database/migrations/2026_04_12_000002_add_ses_message_id_to_outgoing_messages_table.php b/backend/database/migrations/2026_04_12_000002_add_ses_message_id_to_outgoing_messages_table.php new file mode 100644 index 0000000000..e166c9238a --- /dev/null +++ b/backend/database/migrations/2026_04_12_000002_add_ses_message_id_to_outgoing_messages_table.php @@ -0,0 +1,23 @@ +string('ses_message_id')->nullable()->after('status'); + $table->index('ses_message_id'); + }); + } + + public function down(): void + { + Schema::table('outgoing_messages', static function (Blueprint $table) { + $table->dropIndex(['ses_message_id']); + $table->dropColumn('ses_message_id'); + }); + } +}; diff --git a/backend/database/migrations/2026_04_13_000000_add_resolved_at_to_outgoing_transaction_messages.php b/backend/database/migrations/2026_04_13_000000_add_resolved_at_to_outgoing_transaction_messages.php new file mode 100644 index 0000000000..7e9b69e714 --- /dev/null +++ b/backend/database/migrations/2026_04_13_000000_add_resolved_at_to_outgoing_transaction_messages.php @@ -0,0 +1,21 @@ +timestamp('resolved_at')->nullable()->after('ses_message_id'); + }); + } + + public function down(): void + { + Schema::table('outgoing_transaction_messages', function (Blueprint $table) { + $table->dropColumn('resolved_at'); + }); + } +}; diff --git a/backend/database/migrations/2026_04_13_000001_create_outgoing_transaction_messages_table.php b/backend/database/migrations/2026_04_13_000001_create_outgoing_transaction_messages_table.php new file mode 100644 index 0000000000..d56a973712 --- /dev/null +++ b/backend/database/migrations/2026_04_13_000001_create_outgoing_transaction_messages_table.php @@ -0,0 +1,37 @@ +id(); + + $table->foreignId('event_id')->nullable()->constrained()->cascadeOnDelete(); + $table->foreignId('order_id')->nullable()->constrained()->nullOnDelete(); + $table->foreignId('attendee_id')->nullable()->constrained()->nullOnDelete(); + + $table->string('email_type'); + $table->string('recipient'); + $table->string('subject'); + $table->string('status'); + $table->string('ses_message_id')->nullable(); + + $table->index('event_id'); + $table->index(['recipient', 'created_at']); + $table->index('status'); + $table->index('ses_message_id'); + + $table->timestamps(); + $table->softDeletes(); + }); + } + + public function down(): void + { + Schema::dropIfExists('outgoing_transaction_messages'); + } +}; diff --git a/backend/database/migrations/2026_04_14_000000_add_resolved_at_to_outgoing_messages.php b/backend/database/migrations/2026_04_14_000000_add_resolved_at_to_outgoing_messages.php new file mode 100644 index 0000000000..2956cb7e02 --- /dev/null +++ b/backend/database/migrations/2026_04_14_000000_add_resolved_at_to_outgoing_messages.php @@ -0,0 +1,21 @@ +timestamp('resolved_at')->nullable()->after('ses_message_id'); + }); + } + + public function down(): void + { + Schema::table('outgoing_messages', function (Blueprint $table) { + $table->dropColumn('resolved_at'); + }); + } +}; diff --git a/backend/database/migrations/2026_04_14_000001_add_retry_columns_to_outgoing_messages_tables.php b/backend/database/migrations/2026_04_14_000001_add_retry_columns_to_outgoing_messages_tables.php new file mode 100644 index 0000000000..043ef9ffa6 --- /dev/null +++ b/backend/database/migrations/2026_04_14_000001_add_retry_columns_to_outgoing_messages_tables.php @@ -0,0 +1,33 @@ +unsignedBigInteger('retry_for_id')->nullable()->after('resolved_at'); + $table->index('retry_for_id'); + }); + + Schema::table('outgoing_messages', function (Blueprint $table) { + $table->unsignedBigInteger('retry_for_id')->nullable()->after('resolved_at'); + $table->index('retry_for_id'); + }); + } + + public function down(): void + { + Schema::table('outgoing_transaction_messages', function (Blueprint $table) { + $table->dropIndex(['retry_for_id']); + $table->dropColumn('retry_for_id'); + }); + + Schema::table('outgoing_messages', function (Blueprint $table) { + $table->dropIndex(['retry_for_id']); + $table->dropColumn('retry_for_id'); + }); + } +}; diff --git a/backend/database/migrations/2026_04_14_000002_add_resolution_type_to_outgoing_messages_tables.php b/backend/database/migrations/2026_04_14_000002_add_resolution_type_to_outgoing_messages_tables.php new file mode 100644 index 0000000000..9a9da08999 --- /dev/null +++ b/backend/database/migrations/2026_04_14_000002_add_resolution_type_to_outgoing_messages_tables.php @@ -0,0 +1,29 @@ +string('resolution_type')->nullable()->after('resolved_at'); + }); + + Schema::table('outgoing_messages', function (Blueprint $table) { + $table->string('resolution_type')->nullable()->after('resolved_at'); + }); + } + + public function down(): void + { + Schema::table('outgoing_transaction_messages', function (Blueprint $table) { + $table->dropColumn('resolution_type'); + }); + + Schema::table('outgoing_messages', function (Blueprint $table) { + $table->dropColumn('resolution_type'); + }); + } +}; diff --git a/backend/docs/ses-bounce-handling.md b/backend/docs/ses-bounce-handling.md new file mode 100644 index 0000000000..029138504f --- /dev/null +++ b/backend/docs/ses-bounce-handling.md @@ -0,0 +1,223 @@ +# SES Bounce/Complaint Handling + +## What This Does + +Adds an SNS webhook endpoint that ingests SES bounce and complaint notifications, maintains an in-app suppression list, and checks that list before sending emails. Different behavior for transactional vs. marketing emails: + +- **Permanent bounces** (invalid address): suppress ALL email types +- **Complaints** (user marked spam): suppress marketing only — transactional emails to paying customers still send +- **Transient bounces**: suppress marketing only — transactional may succeed on retry + +## New Files + +| File | Purpose | +|------|---------| +| `database/migrations/2026_03_09_000000_create_email_suppressions_table.php` | Creates `email_suppressions` table | +| `app/Models/EmailSuppression.php` | Eloquent model | +| `app/DomainObjects/EmailSuppressionDomainObject.php` | Domain object | +| `app/DomainObjects/Generated/EmailSuppressionDomainObjectAbstract.php` | Generated abstract with field constants | +| `app/DomainObjects/Status/EmailSuppressionReasonEnum.php` | `BOUNCE`, `COMPLAINT` | +| `app/DomainObjects/Status/EmailSuppressionSourceEnum.php` | `SES_NOTIFICATION`, `MANUAL` | +| `app/Repository/Interfaces/EmailSuppressionRepositoryInterface.php` | Repository interface | +| `app/Repository/Eloquent/EmailSuppressionRepository.php` | Repository implementation | +| `app/Services/Domain/Email/EmailSuppressionService.php` | Suppression check/create/remove logic | +| `app/Http/Actions/Common/Webhooks/SesIncomingWebhookAction.php` | Webhook endpoint (mirrors Stripe pattern) | +| `app/Services/Application/Handlers/Email/Ses/DTO/SesWebhookDTO.php` | DTO for webhook payload | +| `app/Services/Application/Handlers/Email/Ses/IncomingSesWebhookHandler.php` | SNS message validation, routing, dedup | +| `app/Services/Domain/Email/Ses/EventHandlers/BounceHandler.php` | Creates suppression for bounces | +| `app/Services/Domain/Email/Ses/EventHandlers/ComplaintHandler.php` | Creates suppression for complaints | + +## Modified Files + +| File | Change | +|------|--------| +| `routes/api.php` | Added `POST /api/public/webhooks/ses` | +| `app/Providers/RepositoryServiceProvider.php` | Registered `EmailSuppressionRepository` | +| `app/Jobs/Event/SendEventEmailJob.php` | Checks suppression before sending, logs `SUPPRESSED` status | +| `app/Services/Domain/Mail/SendEventEmailMessagesService.php` | Pre-filters suppressed addresses before dispatching jobs | +| `app/DomainObjects/Status/OutgoingMessageStatus.php` | Added `SUPPRESSED` case | +| `config/services.php` | Added `sns_topic_arn`, `sns_verify_signature`, `suppression_enabled` under `ses` | +| `.env.example` | Added `AWS_SNS_TOPIC_ARN`, `AWS_SNS_VERIFY_SIGNATURE`, `SES_SUPPRESSION_ENABLED` | + +## Configuration + +Add to your `.env`: + +```env +AWS_SNS_TOPIC_ARN=arn:aws:sns:us-east-1:123456789:ses-notifications +AWS_SNS_VERIFY_SIGNATURE=true +SES_SUPPRESSION_ENABLED=true +``` + +Set `SES_SUPPRESSION_ENABLED=false` (default) to disable all suppression checks without removing the code. + +## Local Development Testing + +### 1. Run the migration + +```bash +cd backend +php artisan migrate +``` + +Verify the table exists: + +```bash +php artisan tinker +>>> Schema::hasTable('email_suppressions') +# Should return true +``` + +### 2. Test the webhook endpoint accepts POST requests + +```bash +# Should return 204 (even with garbage payload — errors are handled async) +curl -s -o /dev/null -w "%{http_code}" \ + -X POST http://localhost:1234/api/public/webhooks/ses \ + -H "Content-Type: application/json" \ + -d '{"Type": "Notification", "Message": "{}"}' +``` + +Expected: `204` + +### 3. Test SNS SubscriptionConfirmation handling + +```bash +curl -X POST http://localhost:1234/api/public/webhooks/ses \ + -H "Content-Type: application/json" \ + -d '{ + "Type": "SubscriptionConfirmation", + "TopicArn": "arn:aws:sns:us-east-1:123456789:ses-notifications", + "SubscribeURL": "https://httpbin.org/get" + }' +``` + +Check logs — you should see "Confirming SNS subscription" and an HTTP GET to the SubscribeURL. + +### 4. Test bounce notification creates a suppression record + +Make sure `SES_SUPPRESSION_ENABLED=true` in your `.env`, then: + +```bash +curl -X POST http://localhost:1234/api/public/webhooks/ses \ + -H "Content-Type: application/json" \ + -d '{ + "Type": "Notification", + "MessageId": "test-bounce-001", + "TopicArn": "'"$AWS_SNS_TOPIC_ARN"'", + "Message": "{\"notificationType\":\"Bounce\",\"bounce\":{\"bounceType\":\"Permanent\",\"bounceSubType\":\"General\",\"bouncedRecipients\":[{\"emailAddress\":\"bounce-test@example.com\"}]}}" + }' +``` + +If your queue is `sync`, the record is created immediately. If async, process the queue first: + +```bash +php artisan queue:work --once +``` + +Verify the record: + +```bash +php artisan tinker +>>> DB::table('email_suppressions')->where('email', 'bounce-test@example.com')->first() +``` + +You should see a row with `reason=bounce`, `bounce_type=Permanent`, `source=ses_notification`. + +### 5. Test complaint notification + +```bash +curl -X POST http://localhost:1234/api/public/webhooks/ses \ + -H "Content-Type: application/json" \ + -d '{ + "Type": "Notification", + "MessageId": "test-complaint-001", + "TopicArn": "'"$AWS_SNS_TOPIC_ARN"'", + "Message": "{\"notificationType\":\"Complaint\",\"complaint\":{\"complaintFeedbackType\":\"abuse\",\"complainedRecipients\":[{\"emailAddress\":\"complaint-test@example.com\"}]}}" + }' +``` + +### 6. Test suppression logic in tinker + +```bash +php artisan tinker +``` + +```php +$svc = app(\HiEvents\Services\Domain\Email\EmailSuppressionService::class); + +// Permanent bounce — suppressed for ALL types +$svc->isEmailSuppressed('bounce-test@example.com', null, 'marketing'); // true +$svc->isEmailSuppressed('bounce-test@example.com', null, 'transactional'); // true + +// Complaint — suppressed for marketing only +$svc->isEmailSuppressed('complaint-test@example.com', null, 'marketing'); // true +$svc->isEmailSuppressed('complaint-test@example.com', null, 'transactional'); // false + +// Clean address — never suppressed +$svc->isEmailSuppressed('clean@example.com', null, 'marketing'); // false +``` + +### 7. Test deduplication + +Send the same bounce notification again (same `MessageId`): + +```bash +curl -X POST http://localhost:1234/api/public/webhooks/ses \ + -H "Content-Type: application/json" \ + -d '{ + "Type": "Notification", + "MessageId": "test-bounce-001", + "TopicArn": "'"$AWS_SNS_TOPIC_ARN"'", + "Message": "{\"notificationType\":\"Bounce\",\"bounce\":{\"bounceType\":\"Permanent\",\"bounceSubType\":\"General\",\"bouncedRecipients\":[{\"emailAddress\":\"bounce-test@example.com\"}]}}" + }' +``` + +Check logs — should see "SNS message already handled". No duplicate rows in `email_suppressions`. + +### 8. Test email sending with suppressed address + +This requires an event with messages set up. The easiest path: + +1. Create an event and add some attendees via the UI +2. Manually insert a suppression for one attendee's email: + ```php + // In tinker + $svc = app(\HiEvents\Services\Domain\Email\EmailSuppressionService::class); + $svc->suppressEmail( + email: 'attendee@example.com', + reason: 'bounce', + source: 'manual', + bounceType: 'Permanent', + ); + ``` +3. Send a message to all attendees via the UI +4. Check `outgoing_messages` table — the suppressed attendee should have status `SUPPRESSED` + +### 9. Test removing a suppression (un-suppress) + +```php +// In tinker +$svc = app(\HiEvents\Services\Domain\Email\EmailSuppressionService::class); +$svc->removeSuppression('bounce-test@example.com'); + +// Verify soft-deleted +DB::table('email_suppressions') + ->where('email', 'bounce-test@example.com') + ->whereNotNull('deleted_at') + ->exists(); // true + +// No longer suppressed +$svc->isEmailSuppressed('bounce-test@example.com', null, 'marketing'); // false +``` + +## Production Setup with Real SES + +1. In the SES console, set up a Configuration Set with an SNS destination for Bounce and Complaint events +2. Create an SNS topic and subscribe the webhook URL: `https://your-domain.com/api/public/webhooks/ses` +3. SNS will send a `SubscriptionConfirmation` — the handler auto-confirms it +4. Set `AWS_SNS_TOPIC_ARN` to your topic ARN +5. Set `SES_SUPPRESSION_ENABLED=true` +6. Test with SES simulator addresses: + - `bounce@simulator.amazonses.com` — triggers a bounce notification + - `complaint@simulator.amazonses.com` — triggers a complaint notification diff --git a/backend/routes/api.php b/backend/routes/api.php index e3947d0804..13dc6bd267 100644 --- a/backend/routes/api.php +++ b/backend/routes/api.php @@ -46,6 +46,7 @@ use HiEvents\Http\Actions\CheckInLists\Public\GetCheckInListPublicAction; use HiEvents\Http\Actions\CheckInLists\UpdateCheckInListAction; use HiEvents\Http\Actions\Common\GetColorThemesAction; +use HiEvents\Http\Actions\Common\Webhooks\SesIncomingWebhookAction; use HiEvents\Http\Actions\Common\Webhooks\StripeIncomingWebhookAction; use HiEvents\Http\Actions\Events\CreateEventAction; use HiEvents\Http\Actions\Events\DuplicateEventAction; @@ -82,7 +83,13 @@ use HiEvents\Http\Actions\Messages\CancelMessageAction; use HiEvents\Http\Actions\Messages\GetMessageRecipientsAction; use HiEvents\Http\Actions\Messages\GetMessagesAction; +use HiEvents\Http\Actions\Messages\GetOutgoingMessagesAction; +use HiEvents\Http\Actions\Messages\ResendOutgoingMessageAction; use HiEvents\Http\Actions\Messages\SendMessageAction; +use HiEvents\Http\Actions\DeliveryIssue\GetDeliveryIssuesAction; +use HiEvents\Http\Actions\DeliveryIssue\ResolveDeliveryIssueAction; +use HiEvents\Http\Actions\TransactionMessages\GetTransactionMessagesAction; +use HiEvents\Http\Actions\TransactionMessages\ResendTransactionMessageAction; use HiEvents\Http\Actions\Orders\CancelOrderAction; use HiEvents\Http\Actions\Orders\DownloadOrderInvoiceAction; use HiEvents\Http\Actions\Orders\EditOrderAction; @@ -182,6 +189,9 @@ use HiEvents\Http\Actions\Admin\Configurations\DeleteConfigurationAction; use HiEvents\Http\Actions\Admin\Configurations\GetAllConfigurationsAction; use HiEvents\Http\Actions\Admin\Configurations\UpdateConfigurationAction; +use HiEvents\Http\Actions\Admin\EmailSuppressions\CreateEmailSuppressionAction; +use HiEvents\Http\Actions\Admin\EmailSuppressions\DeleteEmailSuppressionAction; +use HiEvents\Http\Actions\Admin\EmailSuppressions\GetAllEmailSuppressionsAction; use HiEvents\Http\Actions\Admin\Events\GetAllEventsAction as GetAllAdminEventsAction; use HiEvents\Http\Actions\Admin\Events\GetUpcomingEventsAction; use HiEvents\Http\Actions\Admin\FailedJobs\DeleteAllFailedJobsAction; @@ -403,6 +413,16 @@ function (Router $router): void { $router->get('/events/{event_id}/messages', GetMessagesAction::class); $router->post('/events/{event_id}/messages/{message_id}/cancel', CancelMessageAction::class); $router->get('/events/{event_id}/messages/{message_id}/recipients', GetMessageRecipientsAction::class); + $router->get('/events/{event_id}/outgoing-messages', GetOutgoingMessagesAction::class); + $router->post('/events/{event_id}/outgoing-messages/{message_id}/resend', ResendOutgoingMessageAction::class); + + // Transaction Messages + $router->get('/events/{event_id}/transaction-messages', GetTransactionMessagesAction::class); + $router->post('/events/{event_id}/transaction-messages/{message_id}/resend', ResendTransactionMessageAction::class); + + // Delivery Issues (union of transaction + announcement failures) + $router->get('/events/{event_id}/delivery-issues', GetDeliveryIssuesAction::class); + $router->post('/events/{event_id}/delivery-issues/{message_id}/resolve', ResolveDeliveryIssueAction::class); // Event Settings $router->get('/events/{event_id}/settings', GetEventSettingsAction::class); @@ -478,6 +498,11 @@ function (Router $router): void { $router->get('/messages', GetAllAdminMessagesAction::class); $router->post('/messages/{message_id}/approve', ApproveMessageAction::class); + // Email Suppressions + $router->get('/email-suppressions', GetAllEmailSuppressionsAction::class); + $router->post('/email-suppressions', CreateEmailSuppressionAction::class); + $router->delete('/email-suppressions/{suppression_id}', DeleteEmailSuppressionAction::class); + // Messaging Tiers $router->get('/messaging-tiers', GetMessagingTiersAction::class); $router->put('/accounts/{account_id}/messaging-tier', UpdateAccountMessagingTierAction::class); @@ -532,6 +557,7 @@ function (Router $router): void { // Webhooks $router->post('/webhooks/stripe', StripeIncomingWebhookAction::class); + $router->post('/webhooks/ses', SesIncomingWebhookAction::class); // Check-In $router->get('/check-in-lists/{check_in_list_short_id}', GetCheckInListPublicAction::class); diff --git a/backend/tests/Feature/Http/Actions/Common/Webhooks/SesIncomingWebhookActionTest.php b/backend/tests/Feature/Http/Actions/Common/Webhooks/SesIncomingWebhookActionTest.php new file mode 100644 index 0000000000..9d538b4cd8 --- /dev/null +++ b/backend/tests/Feature/Http/Actions/Common/Webhooks/SesIncomingWebhookActionTest.php @@ -0,0 +1,125 @@ + false]); + config(['services.ses.suppression_enabled' => true]); + config(['services.ses.sns_topic_arn' => null]); + + Cache::flush(); + } + + public function testBounceWebhookPersistsSuppressionRecord(): void + { + $payload = $this->makeBouncePayload('bounce-test@example.com', 'msg-bounce-001'); + + $response = $this->postJson(self::WEBHOOK_ROUTE, $payload); + + $response->assertStatus(204); + + $this->assertDatabaseHas('email_suppressions', [ + 'email' => 'bounce-test@example.com', + 'reason' => 'bounce', + 'bounce_type' => 'Permanent', + 'source' => 'ses_notification', + ]); + } + + public function testComplaintWebhookPersistsSuppressionRecord(): void + { + $payload = $this->makeComplaintPayload('complaint-test@example.com', 'msg-complaint-001'); + + $response = $this->postJson(self::WEBHOOK_ROUTE, $payload); + + $response->assertStatus(204); + + $this->assertDatabaseHas('email_suppressions', [ + 'email' => 'complaint-test@example.com', + 'reason' => 'complaint', + 'source' => 'ses_notification', + ]); + } + + public function testInvalidJsonReturns400(): void + { + $response = $this->call('POST', self::WEBHOOK_ROUTE, [], [], [], [ + 'CONTENT_TYPE' => 'application/json', + ], 'not valid json'); + + $response->assertStatus(400); + } + + public function testDuplicateMessageIsIdempotent(): void + { + $payload = $this->makeBouncePayload('duplicate-test@example.com', 'msg-dup-001'); + + $first = $this->postJson(self::WEBHOOK_ROUTE, $payload); + $first->assertStatus(204); + + $second = $this->postJson(self::WEBHOOK_ROUTE, $payload); + $second->assertStatus(204); + + $this->assertDatabaseHas('email_suppressions', [ + 'email' => 'duplicate-test@example.com', + ]); + $this->assertEquals( + 1, + \DB::table('email_suppressions')->where('email', 'duplicate-test@example.com')->count(), + 'Duplicate SNS message should not create a second suppression record', + ); + } + + private function makeBouncePayload(string $email, string $messageId): array + { + return [ + 'Type' => 'Notification', + 'MessageId' => $messageId, + 'TopicArn' => 'arn:aws:sns:us-east-1:123456789:ses-bounces', + 'Message' => json_encode([ + 'notificationType' => 'Bounce', + 'bounce' => [ + 'bounceType' => 'Permanent', + 'bounceSubType' => 'General', + 'bouncedRecipients' => [ + ['emailAddress' => $email], + ], + ], + ]), + 'Timestamp' => '2026-04-11T00:00:00.000Z', + ]; + } + + private function makeComplaintPayload(string $email, string $messageId): array + { + return [ + 'Type' => 'Notification', + 'MessageId' => $messageId, + 'TopicArn' => 'arn:aws:sns:us-east-1:123456789:ses-bounces', + 'Message' => json_encode([ + 'notificationType' => 'Complaint', + 'complaint' => [ + 'complaintFeedbackType' => 'abuse', + 'complainedRecipients' => [ + ['emailAddress' => $email], + ], + ], + ]), + 'Timestamp' => '2026-04-11T00:00:00.000Z', + ]; + } +} diff --git a/backend/tests/Unit/Services/Application/Handlers/Email/Ses/IncomingSesWebhookHandlerTest.php b/backend/tests/Unit/Services/Application/Handlers/Email/Ses/IncomingSesWebhookHandlerTest.php new file mode 100644 index 0000000000..d4c1a3882f --- /dev/null +++ b/backend/tests/Unit/Services/Application/Handlers/Email/Ses/IncomingSesWebhookHandlerTest.php @@ -0,0 +1,243 @@ +bounceHandler = m::mock(BounceHandler::class); + $this->complaintHandler = m::mock(ComplaintHandler::class); + $this->deliveryHandler = m::mock(DeliveryHandler::class); + $this->signatureService = m::mock(SnsSignatureVerificationService::class); + $this->logger = m::mock(Logger::class)->shouldIgnoreMissing(); + $this->cache = m::mock(Repository::class); + + $this->handler = new IncomingSesWebhookHandler( + $this->bounceHandler, + $this->complaintHandler, + $this->deliveryHandler, + $this->signatureService, + $this->logger, + $this->cache, + ); + } + + public function testHandlesBounceNotificationSuccessfully(): void + { + $innerMessage = json_encode([ + 'notificationType' => 'Bounce', + 'bounce' => ['bouncedRecipients' => [['emailAddress' => 'test@example.com']]], + ]); + + $payload = json_encode([ + 'Type' => 'Notification', + 'MessageId' => 'msg-123', + 'Message' => $innerMessage, + ]); + + $this->signatureService->shouldReceive('verify')->once(); + $this->cache->shouldReceive('has')->with('ses_sns_message_msg-123')->andReturn(false); + $this->cache->shouldReceive('put')->once(); + + $this->bounceHandler->shouldReceive('handle') + ->once() + ->withArgs(function ($message, $snsPayload) { + return $message['notificationType'] === 'Bounce'; + }); + + config(['services.ses.sns_topic_arn' => null]); + + $this->handler->handle(new SesWebhookDTO(payload: $payload)); + } + + public function testHandlesComplaintNotificationSuccessfully(): void + { + $innerMessage = json_encode([ + 'notificationType' => 'Complaint', + 'complaint' => ['complainedRecipients' => [['emailAddress' => 'test@example.com']]], + ]); + + $payload = json_encode([ + 'Type' => 'Notification', + 'MessageId' => 'msg-456', + 'Message' => $innerMessage, + ]); + + $this->signatureService->shouldReceive('verify')->once(); + $this->cache->shouldReceive('has')->with('ses_sns_message_msg-456')->andReturn(false); + $this->cache->shouldReceive('put')->once(); + + $this->complaintHandler->shouldReceive('handle') + ->once() + ->withArgs(function ($message) { + return $message['notificationType'] === 'Complaint'; + }); + + config(['services.ses.sns_topic_arn' => null]); + + $this->handler->handle(new SesWebhookDTO(payload: $payload)); + } + + public function testHandlesDeliveryNotificationSuccessfully(): void + { + $innerMessage = json_encode([ + 'notificationType' => 'Delivery', + 'delivery' => ['recipients' => ['test@example.com']], + 'mail' => ['messageId' => 'ses-msg-001'], + ]); + + $payload = json_encode([ + 'Type' => 'Notification', + 'MessageId' => 'msg-delivery-1', + 'Message' => $innerMessage, + ]); + + $this->signatureService->shouldReceive('verify')->once(); + $this->cache->shouldReceive('has')->with('ses_sns_message_msg-delivery-1')->andReturn(false); + $this->cache->shouldReceive('put')->once(); + + $this->deliveryHandler->shouldReceive('handle') + ->once() + ->withArgs(function ($message) { + return $message['notificationType'] === 'Delivery'; + }); + + config(['services.ses.sns_topic_arn' => null]); + + $this->handler->handle(new SesWebhookDTO(payload: $payload)); + } + + public function testSkipsDuplicateSnsMessage(): void + { + $payload = json_encode([ + 'Type' => 'Notification', + 'MessageId' => 'msg-duplicate', + 'Message' => '{}', + ]); + + $this->signatureService->shouldReceive('verify')->once(); + $this->cache->shouldReceive('has')->with('ses_sns_message_msg-duplicate')->andReturn(true); + + $this->bounceHandler->shouldNotReceive('handle'); + $this->complaintHandler->shouldNotReceive('handle'); + + config(['services.ses.sns_topic_arn' => null]); + + $this->handler->handle(new SesWebhookDTO(payload: $payload)); + } + + public function testRejectsMismatchedTopicArn(): void + { + $payload = json_encode([ + 'Type' => 'Notification', + 'MessageId' => 'msg-789', + 'TopicArn' => 'arn:aws:sns:us-east-1:123:wrong-topic', + 'Message' => '{}', + ]); + + $this->signatureService->shouldReceive('verify')->once(); + + config(['services.ses.sns_topic_arn' => 'arn:aws:sns:us-east-1:123:correct-topic']); + + $this->bounceHandler->shouldNotReceive('handle'); + $this->complaintHandler->shouldNotReceive('handle'); + + $this->handler->handle(new SesWebhookDTO(payload: $payload)); + } + + public function testThrowsOnInvalidJson(): void + { + $this->expectException(JsonException::class); + + $this->handler->handle(new SesWebhookDTO(payload: 'not valid json')); + } + + public function testRejectsInvalidSnsSignature(): void + { + $payload = json_encode(['Type' => 'Notification', 'MessageId' => 'msg-bad']); + + $this->signatureService->shouldReceive('verify') + ->once() + ->andThrow(new SnsSignatureVerificationException('Signature invalid')); + + $this->bounceHandler->shouldNotReceive('handle'); + + $this->expectException(SnsSignatureVerificationException::class); + + $this->handler->handle(new SesWebhookDTO(payload: $payload)); + } + + public function testSkipsNonNotificationType(): void + { + $payload = json_encode([ + 'Type' => 'UnsubscribeConfirmation', + 'MessageId' => 'msg-unsub', + ]); + + $this->signatureService->shouldReceive('verify')->once(); + + $this->bounceHandler->shouldNotReceive('handle'); + $this->complaintHandler->shouldNotReceive('handle'); + + $this->handler->handle(new SesWebhookDTO(payload: $payload)); + } + + public function testHandlesSubscriptionConfirmationWithValidUrl(): void + { + $payload = json_encode([ + 'Type' => 'SubscriptionConfirmation', + 'SubscribeURL' => 'https://sns.us-east-1.amazonaws.com/confirm?token=abc', + 'TopicArn' => 'arn:aws:sns:us-east-1:123:test', + ]); + + $this->signatureService->shouldReceive('verify')->once(); + + \Illuminate\Support\Facades\Http::fake([ + 'sns.us-east-1.amazonaws.com/*' => \Illuminate\Support\Facades\Http::response('OK', 200), + ]); + + $this->handler->handle(new SesWebhookDTO(payload: $payload)); + + \Illuminate\Support\Facades\Http::assertSentCount(1); + } + + public function testRejectsSubscriptionConfirmationWithInvalidUrl(): void + { + $payload = json_encode([ + 'Type' => 'SubscriptionConfirmation', + 'SubscribeURL' => 'http://evil.com/steal-data', + 'TopicArn' => 'arn:aws:sns:us-east-1:123:test', + ]); + + $this->signatureService->shouldReceive('verify')->once(); + + \Illuminate\Support\Facades\Http::fake(); + + $this->handler->handle(new SesWebhookDTO(payload: $payload)); + + \Illuminate\Support\Facades\Http::assertNothingSent(); + } +} diff --git a/backend/tests/Unit/Services/Domain/Email/EmailSuppressionServiceTest.php b/backend/tests/Unit/Services/Domain/Email/EmailSuppressionServiceTest.php new file mode 100644 index 0000000000..e328f83753 --- /dev/null +++ b/backend/tests/Unit/Services/Domain/Email/EmailSuppressionServiceTest.php @@ -0,0 +1,180 @@ +repository = m::mock(EmailSuppressionRepositoryInterface::class); + $this->service = new EmailSuppressionService($this->repository); + } + + public function testIsEmailSuppressedReturnsFalseWhenFeatureDisabled(): void + { + config(['services.ses.suppression_enabled' => false]); + + $result = $this->service->isEmailSuppressed('test@example.com', 1); + + $this->assertFalse($result); + } + + public function testIsEmailSuppressedReturnsFalseWhenNoSuppressionsExist(): void + { + config(['services.ses.suppression_enabled' => true]); + + $this->repository->shouldReceive('findByEmail') + ->once() + ->with('test@example.com', 1) + ->andReturn(new Collection([])); + + $result = $this->service->isEmailSuppressed('test@example.com', 1); + + $this->assertFalse($result); + } + + public function testPermanentBounceSuppressesAllEmailTypes(): void + { + config(['services.ses.suppression_enabled' => true]); + + $suppression = m::mock(EmailSuppressionDomainObject::class); + $suppression->shouldReceive('getReason')->andReturn(EmailSuppressionReasonEnum::BOUNCE->value); + $suppression->shouldReceive('getBounceType')->andReturn('Permanent'); + + $this->repository->shouldReceive('findByEmail') + ->andReturn(new Collection([$suppression])); + + $this->assertTrue($this->service->isEmailSuppressed('test@example.com', 1, 'marketing')); + $this->assertTrue($this->service->isEmailSuppressed('test@example.com', 1, 'transactional')); + } + + public function testTransientBounceSuppressesOnlyMarketing(): void + { + config(['services.ses.suppression_enabled' => true]); + + $suppression = m::mock(EmailSuppressionDomainObject::class); + $suppression->shouldReceive('getReason')->andReturn(EmailSuppressionReasonEnum::BOUNCE->value); + $suppression->shouldReceive('getBounceType')->andReturn('Transient'); + + $this->repository->shouldReceive('findByEmail') + ->andReturn(new Collection([$suppression])); + + $this->assertTrue($this->service->isEmailSuppressed('test@example.com', 1, 'marketing')); + $this->assertFalse($this->service->isEmailSuppressed('test@example.com', 1, 'transactional')); + } + + public function testComplaintSuppressesOnlyMarketing(): void + { + config(['services.ses.suppression_enabled' => true]); + + $suppression = m::mock(EmailSuppressionDomainObject::class); + $suppression->shouldReceive('getReason')->andReturn(EmailSuppressionReasonEnum::COMPLAINT->value); + + $this->repository->shouldReceive('findByEmail') + ->andReturn(new Collection([$suppression])); + + $this->assertTrue($this->service->isEmailSuppressed('test@example.com', 1, 'marketing')); + $this->assertFalse($this->service->isEmailSuppressed('test@example.com', 1, 'transactional')); + } + + public function testSuppressEmailUsesFirstOrCreate(): void + { + $suppression = m::mock(EmailSuppressionDomainObject::class); + + $this->repository->shouldReceive('findOrCreateSuppression') + ->once() + ->withArgs(function ($unique, $additional) { + return $unique['email'] === 'test@example.com' + && $unique['reason'] === 'bounce' + && $additional['bounce_type'] === 'Permanent'; + }) + ->andReturn($suppression); + + $result = $this->service->suppressEmail( + email: 'TEST@EXAMPLE.COM', + reason: 'bounce', + source: 'ses_notification', + accountId: 1, + bounceType: 'Permanent', + ); + + $this->assertSame($suppression, $result); + } + + public function testSuppressEmailHandlesDuplicateGracefully(): void + { + $existingSuppression = m::mock(EmailSuppressionDomainObject::class); + + $this->repository->shouldReceive('findOrCreateSuppression') + ->twice() + ->andReturn($existingSuppression); + + $result1 = $this->service->suppressEmail( + email: 'test@example.com', + reason: 'bounce', + source: 'ses_notification', + bounceType: 'Permanent', + ); + + $result2 = $this->service->suppressEmail( + email: 'test@example.com', + reason: 'bounce', + source: 'ses_notification', + bounceType: 'Permanent', + ); + + $this->assertSame($existingSuppression, $result1); + $this->assertSame($existingSuppression, $result2); + } + + public function testRemoveSuppressionSoftDeletesMatchingRecords(): void + { + $suppression1 = m::mock(EmailSuppressionDomainObject::class); + $suppression1->shouldReceive('getId')->andReturn(1); + $suppression2 = m::mock(EmailSuppressionDomainObject::class); + $suppression2->shouldReceive('getId')->andReturn(2); + + $this->repository->shouldReceive('findWhere') + ->once() + ->with(['email' => 'test@example.com', 'account_id' => 1]) + ->andReturn(new Collection([$suppression1, $suppression2])); + + $this->repository->shouldReceive('deleteById') + ->once() + ->with(1); + $this->repository->shouldReceive('deleteById') + ->once() + ->with(2); + + $this->service->removeSuppression('test@example.com', 1); + } + + public function testRemoveSuppressionFiltersByReason(): void + { + $suppression = m::mock(EmailSuppressionDomainObject::class); + $suppression->shouldReceive('getId')->andReturn(1); + + $this->repository->shouldReceive('findWhere') + ->once() + ->with(['email' => 'test@example.com', 'reason' => 'bounce']) + ->andReturn(new Collection([$suppression])); + + $this->repository->shouldReceive('deleteById') + ->once() + ->with(1); + + $this->service->removeSuppression('test@example.com', null, 'bounce'); + } +} diff --git a/backend/tests/Unit/Services/Domain/Email/Ses/EventHandlers/BounceHandlerTest.php b/backend/tests/Unit/Services/Domain/Email/Ses/EventHandlers/BounceHandlerTest.php new file mode 100644 index 0000000000..29eef4eb67 --- /dev/null +++ b/backend/tests/Unit/Services/Domain/Email/Ses/EventHandlers/BounceHandlerTest.php @@ -0,0 +1,218 @@ +suppressionService = m::mock(EmailSuppressionService::class); + $this->outgoingMessageRepository = m::mock(OutgoingMessageRepositoryInterface::class); + $this->outgoingMessageRepository->shouldReceive('markAsBounced')->andReturn(false)->byDefault(); + $this->outgoingTransactionMessageRepository = m::mock(OutgoingTransactionMessageRepositoryInterface::class); + $this->outgoingTransactionMessageRepository->shouldReceive('findBySesMessageId')->andReturn(null)->byDefault(); + $this->outgoingTransactionMessageRepository->shouldReceive('findAccountIdByRecipientEmail')->andReturn(null)->byDefault(); + $this->logger = m::mock(Logger::class)->shouldIgnoreMissing(); + + $this->handler = new BounceHandler( + $this->suppressionService, + $this->outgoingMessageRepository, + $this->outgoingTransactionMessageRepository, + $this->logger, + ); + } + + public function testProcessesBouncedRecipients(): void + { + $message = [ + 'bounce' => [ + 'bounceType' => 'Permanent', + 'bounceSubType' => 'General', + 'bouncedRecipients' => [ + ['emailAddress' => 'user1@example.com'], + ['emailAddress' => 'user2@example.com'], + ], + ], + ]; + + $snsPayload = ['MessageId' => 'msg-123']; + + $this->outgoingMessageRepository->shouldReceive('findAccountIdByRecipientEmail') + ->with('user1@example.com')->andReturn(1); + $this->outgoingMessageRepository->shouldReceive('findAccountIdByRecipientEmail') + ->with('user2@example.com')->andReturn(2); + + $suppression = m::mock(EmailSuppressionDomainObject::class); + + $this->suppressionService->shouldReceive('suppressEmail') + ->twice() + ->andReturn($suppression); + + $this->handler->handle($message, $snsPayload); + } + + public function testSkipsEmptyEmailAddresses(): void + { + $message = [ + 'bounce' => [ + 'bounceType' => 'Permanent', + 'bouncedRecipients' => [ + ['emailAddress' => ''], + ['emailAddress' => 'valid@example.com'], + ], + ], + ]; + + $this->outgoingMessageRepository->shouldReceive('findAccountIdByRecipientEmail') + ->once() + ->with('valid@example.com') + ->andReturn(null); + + $suppression = m::mock(EmailSuppressionDomainObject::class); + + $this->suppressionService->shouldReceive('suppressEmail') + ->once() + ->andReturn($suppression); + + $this->handler->handle($message, ['MessageId' => 'msg-456']); + } + + public function testLooksUpAccountIdFromRepository(): void + { + $message = [ + 'bounce' => [ + 'bounceType' => 'Permanent', + 'bouncedRecipients' => [ + ['emailAddress' => 'test@example.com'], + ], + ], + ]; + + $this->outgoingMessageRepository->shouldReceive('findAccountIdByRecipientEmail') + ->once() + ->with('test@example.com') + ->andReturn(42); + + $suppression = m::mock(EmailSuppressionDomainObject::class); + + $this->suppressionService->shouldReceive('suppressEmail') + ->once() + ->withArgs(function ($email, $reason, $source, $accountId) { + return $accountId === 42; + }) + ->andReturn($suppression); + + $this->handler->handle($message, ['MessageId' => 'msg-789']); + } + + public function testMarksOutgoingMessageAsBouncedBySesMessageId(): void + { + $message = [ + 'bounce' => [ + 'bounceType' => 'Permanent', + 'bouncedRecipients' => [ + ['emailAddress' => 'bounced@example.com'], + ], + ], + 'mail' => [ + 'messageId' => 'ses-msg-001', + ], + ]; + + $this->outgoingMessageRepository->shouldReceive('findAccountIdByRecipientEmail') + ->andReturn(1); + + $this->outgoingMessageRepository->shouldReceive('markAsBounced') + ->with('ses-msg-001') + ->once() + ->andReturn(true); + + $suppression = m::mock(EmailSuppressionDomainObject::class); + $this->suppressionService->shouldReceive('suppressEmail') + ->once() + ->andReturn($suppression); + + $this->handler->handle($message, ['MessageId' => 'sns-123']); + } + + public function testMarksTransactionMessageAsBouncedBySesMessageId(): void + { + $message = [ + 'bounce' => [ + 'bounceType' => 'Permanent', + 'bouncedRecipients' => [ + ['emailAddress' => 'bounced@example.com'], + ], + ], + 'mail' => [ + 'messageId' => 'ses-msg-002', + ], + ]; + + $this->outgoingMessageRepository->shouldReceive('findAccountIdByRecipientEmail') + ->andReturn(1); + $this->outgoingMessageRepository->shouldReceive('markAsBounced') + ->with('ses-msg-002') + ->andReturn(false); + + $transactionMessage = m::mock(OutgoingTransactionMessageDomainObject::class); + $transactionMessage->shouldReceive('getId')->andReturn(99); + + $this->outgoingTransactionMessageRepository->shouldReceive('findBySesMessageId') + ->with('ses-msg-002') + ->andReturn($transactionMessage); + + $this->outgoingTransactionMessageRepository->shouldReceive('markAsBounced') + ->with(99) + ->once(); + + $suppression = m::mock(EmailSuppressionDomainObject::class); + $this->suppressionService->shouldReceive('suppressEmail') + ->once() + ->andReturn($suppression); + + $this->handler->handle($message, ['MessageId' => 'sns-456']); + } + + public function testDoesNotMarkWhenNoSesMessageId(): void + { + $message = [ + 'bounce' => [ + 'bounceType' => 'Permanent', + 'bouncedRecipients' => [ + ['emailAddress' => 'nomatch@example.com'], + ], + ], + ]; + + $this->outgoingMessageRepository->shouldReceive('findAccountIdByRecipientEmail') + ->andReturn(1); + + $this->outgoingMessageRepository->shouldNotReceive('markAsBounced'); + $this->outgoingTransactionMessageRepository->shouldNotReceive('findBySesMessageId'); + + $suppression = m::mock(EmailSuppressionDomainObject::class); + $this->suppressionService->shouldReceive('suppressEmail') + ->once() + ->andReturn($suppression); + + $this->handler->handle($message, ['MessageId' => 'sns-789']); + } +} diff --git a/backend/tests/Unit/Services/Domain/Email/Ses/EventHandlers/ComplaintHandlerTest.php b/backend/tests/Unit/Services/Domain/Email/Ses/EventHandlers/ComplaintHandlerTest.php new file mode 100644 index 0000000000..ba022d5358 --- /dev/null +++ b/backend/tests/Unit/Services/Domain/Email/Ses/EventHandlers/ComplaintHandlerTest.php @@ -0,0 +1,150 @@ +suppressionService = m::mock(EmailSuppressionService::class); + $this->outgoingMessageRepository = m::mock(OutgoingMessageRepositoryInterface::class); + $this->outgoingMessageRepository->shouldReceive('markAsBounced')->andReturn(false)->byDefault(); + $this->outgoingTransactionMessageRepository = m::mock(OutgoingTransactionMessageRepositoryInterface::class); + $this->outgoingTransactionMessageRepository->shouldReceive('findBySesMessageId')->andReturn(null)->byDefault(); + $this->outgoingTransactionMessageRepository->shouldReceive('findAccountIdByRecipientEmail')->andReturn(null)->byDefault(); + $this->logger = m::mock(Logger::class)->shouldIgnoreMissing(); + + $this->handler = new ComplaintHandler( + $this->suppressionService, + $this->outgoingMessageRepository, + $this->outgoingTransactionMessageRepository, + $this->logger, + ); + } + + public function testProcessesComplainedRecipients(): void + { + $message = [ + 'complaint' => [ + 'complaintFeedbackType' => 'abuse', + 'complainedRecipients' => [ + ['emailAddress' => 'user1@example.com'], + ['emailAddress' => 'user2@example.com'], + ], + ], + ]; + + $snsPayload = ['MessageId' => 'msg-123']; + + $this->outgoingMessageRepository->shouldReceive('findAccountIdByRecipientEmail') + ->with('user1@example.com')->andReturn(1); + $this->outgoingMessageRepository->shouldReceive('findAccountIdByRecipientEmail') + ->with('user2@example.com')->andReturn(2); + + $suppression = m::mock(EmailSuppressionDomainObject::class); + + $this->suppressionService->shouldReceive('suppressEmail') + ->twice() + ->andReturn($suppression); + + $this->handler->handle($message, $snsPayload); + } + + public function testSkipsEmptyEmailAddresses(): void + { + $message = [ + 'complaint' => [ + 'complainedRecipients' => [ + ['emailAddress' => ''], + ['emailAddress' => 'valid@example.com'], + ], + ], + ]; + + $this->outgoingMessageRepository->shouldReceive('findAccountIdByRecipientEmail') + ->once() + ->with('valid@example.com') + ->andReturn(null); + + $suppression = m::mock(EmailSuppressionDomainObject::class); + + $this->suppressionService->shouldReceive('suppressEmail') + ->once() + ->andReturn($suppression); + + $this->handler->handle($message, ['MessageId' => 'msg-456']); + } + + public function testLooksUpAccountIdFromRepository(): void + { + $message = [ + 'complaint' => [ + 'complainedRecipients' => [ + ['emailAddress' => 'test@example.com'], + ], + ], + ]; + + $this->outgoingMessageRepository->shouldReceive('findAccountIdByRecipientEmail') + ->once() + ->with('test@example.com') + ->andReturn(42); + + $suppression = m::mock(EmailSuppressionDomainObject::class); + + $this->suppressionService->shouldReceive('suppressEmail') + ->once() + ->withArgs(function ($email, $reason, $source, $accountId) { + return $accountId === 42; + }) + ->andReturn($suppression); + + $this->handler->handle($message, ['MessageId' => 'msg-789']); + } + + public function testMarksOutgoingMessageAsBouncedBySesMessageId(): void + { + $message = [ + 'complaint' => [ + 'complaintFeedbackType' => 'abuse', + 'complainedRecipients' => [ + ['emailAddress' => 'complained@example.com'], + ], + ], + 'mail' => [ + 'messageId' => 'ses-msg-001', + ], + ]; + + $this->outgoingMessageRepository->shouldReceive('findAccountIdByRecipientEmail') + ->andReturn(1); + + $this->outgoingMessageRepository->shouldReceive('markAsBounced') + ->with('ses-msg-001') + ->once() + ->andReturn(true); + + $suppression = m::mock(EmailSuppressionDomainObject::class); + $this->suppressionService->shouldReceive('suppressEmail') + ->once() + ->andReturn($suppression); + + $this->handler->handle($message, ['MessageId' => 'sns-123']); + } +} diff --git a/backend/tests/Unit/Services/Domain/Email/Ses/EventHandlers/DeliveryHandlerTest.php b/backend/tests/Unit/Services/Domain/Email/Ses/EventHandlers/DeliveryHandlerTest.php new file mode 100644 index 0000000000..28e09a2486 --- /dev/null +++ b/backend/tests/Unit/Services/Domain/Email/Ses/EventHandlers/DeliveryHandlerTest.php @@ -0,0 +1,86 @@ +outgoingMessageRepository = m::mock(OutgoingMessageRepositoryInterface::class); + $this->outgoingTransactionMessageRepository = m::mock(OutgoingTransactionMessageRepositoryInterface::class); + $this->outgoingTransactionMessageRepository->shouldReceive('findBySesMessageId')->andReturn(null)->byDefault(); + $this->logger = m::mock(Logger::class)->shouldIgnoreMissing(); + + $this->handler = new DeliveryHandler( + $this->outgoingMessageRepository, + $this->outgoingTransactionMessageRepository, + $this->logger, + ); + } + + public function testMarksOutgoingMessageAsDelivered(): void + { + $message = [ + 'delivery' => ['recipients' => ['test@example.com']], + 'mail' => ['messageId' => 'ses-msg-001'], + ]; + + $this->outgoingMessageRepository->shouldReceive('markAsDelivered') + ->with('ses-msg-001') + ->once() + ->andReturn(true); + + $this->handler->handle($message, ['MessageId' => 'sns-123']); + } + + public function testMarksTransactionMessageAsDelivered(): void + { + $message = [ + 'delivery' => ['recipients' => ['test@example.com']], + 'mail' => ['messageId' => 'ses-msg-002'], + ]; + + $this->outgoingMessageRepository->shouldReceive('markAsDelivered') + ->with('ses-msg-002') + ->andReturn(false); + + $transactionMessage = m::mock(OutgoingTransactionMessageDomainObject::class); + $transactionMessage->shouldReceive('getId')->andReturn(42); + + $this->outgoingTransactionMessageRepository->shouldReceive('findBySesMessageId') + ->with('ses-msg-002') + ->andReturn($transactionMessage); + + $this->outgoingTransactionMessageRepository->shouldReceive('markAsDelivered') + ->with(42) + ->once(); + + $this->handler->handle($message, ['MessageId' => 'sns-456']); + } + + public function testSkipsWhenNoSesMessageId(): void + { + $message = [ + 'delivery' => ['recipients' => ['test@example.com']], + ]; + + $this->outgoingMessageRepository->shouldNotReceive('markAsDelivered'); + $this->outgoingTransactionMessageRepository->shouldNotReceive('findBySesMessageId'); + + $this->handler->handle($message, ['MessageId' => 'sns-789']); + } +} diff --git a/backend/tests/Unit/Services/Domain/Email/TransactionalEmailTrackingServiceTest.php b/backend/tests/Unit/Services/Domain/Email/TransactionalEmailTrackingServiceTest.php new file mode 100644 index 0000000000..a4a01f8c12 --- /dev/null +++ b/backend/tests/Unit/Services/Domain/Email/TransactionalEmailTrackingServiceTest.php @@ -0,0 +1,178 @@ +repository = m::mock(OutgoingTransactionMessageRepositoryInterface::class); + $this->suppressionService = m::mock(EmailSuppressionService::class); + $this->mailer = m::mock(Mailer::class); + + $this->service = new TransactionalEmailTrackingService( + $this->repository, + $this->suppressionService, + ); + } + + public function testRecordsSentStatusOnSuccessfulSend(): void + { + $mail = m::mock(Mailable::class); + $pendingMail = m::mock(\Illuminate\Mail\PendingMail::class); + + $this->suppressionService->shouldReceive('isEmailSuppressed') + ->with('test@example.com', 1, 'transactional') + ->andReturn(false); + + $this->mailer->shouldReceive('to') + ->with('test@example.com') + ->andReturn($pendingMail); + $pendingMail->shouldReceive('locale') + ->with('en') + ->andReturn($pendingMail); + $sentMessage = m::mock(\Illuminate\Mail\SentMessage::class); + $sentMessage->shouldReceive('getMessageId')->andReturn('ses-abc-123'); + + $pendingMail->shouldReceive('sendNow') + ->with($mail) + ->once() + ->andReturn($sentMessage); + + $this->repository->shouldReceive('create') + ->once() + ->withArgs(function ($attrs) { + return $attrs['status'] === OutgoingTransactionMessageStatus::SENT->value + && $attrs['email_type'] === TransactionalEmailType::ORDER_SUMMARY->value + && $attrs['recipient'] === 'test@example.com' + && $attrs['event_id'] === 10 + && $attrs['order_id'] === 20 + && $attrs['ses_message_id'] === 'ses-abc-123'; + }); + + $this->service->recordAndSend( + mailer: $this->mailer, + recipient: 'test@example.com', + mail: $mail, + emailType: TransactionalEmailType::ORDER_SUMMARY, + subject: 'Your Order is Confirmed!', + eventId: 10, + orderId: 20, + accountId: 1, + locale: 'en', + ); + } + + public function testRecordsFailedStatusWhenMailerThrows(): void + { + $mail = m::mock(Mailable::class); + $pendingMail = m::mock(\Illuminate\Mail\PendingMail::class); + + $this->suppressionService->shouldReceive('isEmailSuppressed') + ->andReturn(false); + + $this->mailer->shouldReceive('to') + ->andReturn($pendingMail); + $pendingMail->shouldReceive('sendNow') + ->andThrow(new RuntimeException('SMTP error')); + + $this->repository->shouldReceive('create') + ->once() + ->withArgs(function ($attrs) { + return $attrs['status'] === OutgoingTransactionMessageStatus::FAILED->value; + }); + + $this->expectException(RuntimeException::class); + + $this->service->recordAndSend( + mailer: $this->mailer, + recipient: 'fail@example.com', + mail: $mail, + emailType: TransactionalEmailType::ATTENDEE_TICKET, + subject: 'Your Ticket', + eventId: 10, + accountId: 1, + ); + } + + public function testRecordsSuppressedStatusWhenEmailIsSuppressed(): void + { + $mail = m::mock(Mailable::class); + + $this->suppressionService->shouldReceive('isEmailSuppressed') + ->with('suppressed@example.com', 1, 'transactional') + ->andReturn(true); + + $this->mailer->shouldNotReceive('to'); + + $this->repository->shouldReceive('create') + ->once() + ->withArgs(function ($attrs) { + return $attrs['status'] === OutgoingTransactionMessageStatus::SUPPRESSED->value + && $attrs['recipient'] === 'suppressed@example.com' + && $attrs['email_type'] === TransactionalEmailType::ORDER_SUMMARY->value; + }); + + $this->service->recordAndSend( + mailer: $this->mailer, + recipient: 'suppressed@example.com', + mail: $mail, + emailType: TransactionalEmailType::ORDER_SUMMARY, + subject: 'Your Order', + eventId: 10, + accountId: 1, + ); + } + + public function testPassesCorrectAttendeeIdAndOrderId(): void + { + $mail = m::mock(Mailable::class); + $pendingMail = m::mock(\Illuminate\Mail\PendingMail::class); + + $this->suppressionService->shouldReceive('isEmailSuppressed') + ->andReturn(false); + + $this->mailer->shouldReceive('to')->andReturn($pendingMail); + $pendingMail->shouldReceive('locale')->andReturn($pendingMail); + $pendingMail->shouldReceive('sendNow'); + + $this->repository->shouldReceive('create') + ->once() + ->withArgs(function ($attrs) { + return $attrs['event_id'] === 5 + && $attrs['order_id'] === 15 + && $attrs['attendee_id'] === 25 + && $attrs['email_type'] === TransactionalEmailType::ATTENDEE_TICKET->value; + }); + + $this->service->recordAndSend( + mailer: $this->mailer, + recipient: 'attendee@example.com', + mail: $mail, + emailType: TransactionalEmailType::ATTENDEE_TICKET, + subject: 'Your Ticket', + eventId: 5, + orderId: 15, + attendeeId: 25, + accountId: 1, + locale: 'en', + ); + } +} diff --git a/docker/development/scripts/sns-webhook-simulator.sh b/docker/development/scripts/sns-webhook-simulator.sh new file mode 100755 index 0000000000..b733f5bd8d --- /dev/null +++ b/docker/development/scripts/sns-webhook-simulator.sh @@ -0,0 +1,482 @@ +#!/usr/bin/env bash +# +# sns-webhook-simulator.sh — Simulate AWS SNS notifications against the +# local SES webhook for development debugging. +# +# This tool sends unsigned SNS payloads to the webhook endpoint to test +# bounce, complaint, and delivery handling without needing real AWS +# infrastructure (no SNS topics, no SES Configuration Sets, no ngrok). +# +# It also provides helpers to inspect outgoing message status and manage +# the email suppression list during development. +# +# ENVIRONMENT +# Requires the docker/development environment running with these +# settings in docker/development/.env: +# +# AWS_SNS_VERIFY_SIGNATURE=false (skip SNS signature check) +# SES_SUPPRESSION_ENABLED=true (enable suppression logic) +# +# This tool is NOT intended for production use. In production, SNS +# signature verification is enabled, so unsigned payloads are rejected. +# To test the full SES pipeline in production, send to SES simulator +# addresses (e.g. bounce@simulator.amazonses.com) which trigger real +# signed SNS notifications through the webhook. +# +# STATUS TRANSITIONS +# delivery: SENT → DELIVERED (only from SENT) +# bounce: SENT or DELIVERED → BOUNCED +# complaint: SENT or DELIVERED → BOUNCED (also creates suppression) +# +# USAGE +# ./sns-webhook-simulator.sh list +# ./sns-webhook-simulator.sh list-suppressions +# ./sns-webhook-simulator.sh bounce marketing:10 +# ./sns-webhook-simulator.sh bounce transaction:2 +# ./sns-webhook-simulator.sh bounce user@example.com [ses_msg_id] +# ./sns-webhook-simulator.sh bounce-transient marketing:10 +# ./sns-webhook-simulator.sh complaint marketing:10 +# ./sns-webhook-simulator.sh delivery marketing:10 +# ./sns-webhook-simulator.sh unsuppress +# ./sns-webhook-simulator.sh subscribe +# + +set -euo pipefail + +WEBHOOK_URL="${SES_TEST_URL:-https://localhost:8443/api/public/webhooks/ses}" +DOCKER_COMPOSE_DIR="$(cd "$(dirname "$0")/.." && pwd)" +DOCKER_COMPOSE="docker compose -f $DOCKER_COMPOSE_DIR/docker-compose.dev.yml" +DB_NAME="${DB_DATABASE:-backend}" +DB_USER="${DB_USERNAME:-postgres}" + +# --- helpers --- + +generate_uuid() { + uuidgen 2>/dev/null || cat /proc/sys/kernel/random/uuid 2>/dev/null || python3 -c "import uuid; print(uuid.uuid4())" +} + +timestamp() { + date -u +"%Y-%m-%dT%H:%M:%S.000Z" +} + +db_query() { + local sql="$1" + $DOCKER_COMPOSE exec -T pgsql psql -U "$DB_USER" -d "$DB_NAME" -c "$sql" +} + +db_query_value() { + local sql="$1" + $DOCKER_COMPOSE exec -T pgsql psql -U "$DB_USER" -d "$DB_NAME" -t -A -c "$sql" +} + +# Resolve a target argument into email + ses_message_id. +# Accepts either: +# marketing:ID or transaction:ID — looks up from DB +# email@addr [ses_message_id] — uses as-is +resolve_target() { + local arg1="$1" + local arg2="${2:-}" + + TARGET_TABLE="" + TARGET_ROW_ID="" + TARGET_HAS_SES_ID=true + + if [[ "$arg1" == marketing:* ]]; then + TARGET_TABLE="outgoing_messages" + TARGET_ROW_ID="${arg1#marketing:}" + local result + result=$(db_query_value "SELECT recipient || '|' || COALESCE(ses_message_id, '') || '|' || status FROM outgoing_messages WHERE id = $TARGET_ROW_ID AND deleted_at IS NULL") + result=$(echo "$result" | tr -d '[:space:]') + if [ -z "$result" ]; then + echo "ERROR: No marketing message found with id=$TARGET_ROW_ID" >&2 + exit 1 + fi + TARGET_EMAIL=$(echo "$result" | cut -d'|' -f1) + TARGET_SES_MSG_ID=$(echo "$result" | cut -d'|' -f2) + TARGET_STATUS_BEFORE=$(echo "$result" | cut -d'|' -f3) + if [ -z "$TARGET_SES_MSG_ID" ]; then + TARGET_SES_MSG_ID=$(generate_uuid) + TARGET_HAS_SES_ID=false + echo "WARNING: marketing:$TARGET_ROW_ID has no ses_message_id — using generated UUID (status won't update)" >&2 + fi + echo "Resolved marketing:$TARGET_ROW_ID → email=$TARGET_EMAIL, status=$TARGET_STATUS_BEFORE" >&2 + + elif [[ "$arg1" == transaction:* ]]; then + TARGET_TABLE="outgoing_transaction_messages" + TARGET_ROW_ID="${arg1#transaction:}" + local result + result=$(db_query_value "SELECT recipient || '|' || COALESCE(ses_message_id, '') || '|' || status FROM outgoing_transaction_messages WHERE id = $TARGET_ROW_ID AND deleted_at IS NULL") + result=$(echo "$result" | tr -d '[:space:]') + if [ -z "$result" ]; then + echo "ERROR: No transaction message found with id=$TARGET_ROW_ID" >&2 + exit 1 + fi + TARGET_EMAIL=$(echo "$result" | cut -d'|' -f1) + TARGET_SES_MSG_ID=$(echo "$result" | cut -d'|' -f2) + TARGET_STATUS_BEFORE=$(echo "$result" | cut -d'|' -f3) + if [ -z "$TARGET_SES_MSG_ID" ]; then + TARGET_SES_MSG_ID=$(generate_uuid) + TARGET_HAS_SES_ID=false + echo "WARNING: transaction:$TARGET_ROW_ID has no ses_message_id — using generated UUID (status won't update)" >&2 + fi + echo "Resolved transaction:$TARGET_ROW_ID → email=$TARGET_EMAIL, status=$TARGET_STATUS_BEFORE" >&2 + + else + TARGET_EMAIL="$arg1" + TARGET_SES_MSG_ID="${arg2:-$(generate_uuid)}" + TARGET_STATUS_BEFORE="" + fi +} + +# Check status after sending and report what changed +report_status_change() { + local event_type="$1" # delivery, bounce, complaint + + # Only report for DB-targeted messages + if [ -z "$TARGET_TABLE" ] || [ -z "$TARGET_ROW_ID" ]; then + return + fi + + local status_after + status_after=$(db_query_value "SELECT status FROM $TARGET_TABLE WHERE id = $TARGET_ROW_ID AND deleted_at IS NULL") + status_after=$(echo "$status_after" | tr -d '[:space:]') + + if [ "$status_after" = "$TARGET_STATUS_BEFORE" ]; then + echo " Status unchanged: $TARGET_STATUS_BEFORE" + if [ "$TARGET_HAS_SES_ID" = false ]; then + echo " (message has no ses_message_id — webhook can't match it to a DB row)" + else + case "$event_type" in + delivery) + echo " (delivery only updates SENT → DELIVERED; $TARGET_STATUS_BEFORE is not eligible)" ;; + bounce|complaint) + echo " ($event_type only updates SENT or DELIVERED → BOUNCED; $TARGET_STATUS_BEFORE is not eligible)" ;; + esac + fi + else + echo " Status changed: $TARGET_STATUS_BEFORE → $status_after" + fi +} + +send_sns_payload() { + local payload="$1" + local description="$2" + + local http_code + http_code=$(curl -sk -o /dev/null -w "%{http_code}" \ + -X POST "$WEBHOOK_URL" \ + -H "Content-Type: text/plain" \ + -d "$payload") + + if [ "$http_code" = "204" ]; then + echo "OK ($http_code) — $description" + else + echo "FAILED ($http_code) — $description" + echo " URL: $WEBHOOK_URL" + fi +} + +build_sns_envelope() { + local inner_message="$1" + local sns_message_id + sns_message_id=$(generate_uuid) + + local escaped_message + escaped_message=$(echo "$inner_message" | python3 -c "import sys,json; print(json.dumps(sys.stdin.read().strip()))") + + cat < or transaction: to target a specific message" + echo "" + db_query " + SELECT * FROM ( + SELECT + 'marketing' AS type, + om.id, + om.recipient, + LEFT(om.subject, 40) AS subject, + om.status, + CASE WHEN om.ses_message_id IS NOT NULL THEN 'yes' ELSE '-' END AS has_ses_id, + om.created_at + FROM outgoing_messages om + WHERE om.deleted_at IS NULL + ORDER BY om.created_at DESC + LIMIT 10 + ) marketing + UNION ALL + SELECT * FROM ( + SELECT + 'transaction' AS type, + otm.id, + otm.recipient, + LEFT(otm.subject, 40) AS subject, + otm.status, + CASE WHEN otm.ses_message_id IS NOT NULL THEN 'yes' ELSE '-' END AS has_ses_id, + otm.created_at + FROM outgoing_transaction_messages otm + WHERE otm.deleted_at IS NULL + ORDER BY otm.created_at DESC + LIMIT 10 + ) transact + ORDER BY created_at DESC + LIMIT 20; + " +} + +cmd_list_suppressions() { + echo "=== Email Suppressions ===" + echo "" + db_query " + SELECT + id, + email, + reason, + bounce_type, + bounce_sub_type, + complaint_type, + source, + account_id, + created_at + FROM email_suppressions + WHERE deleted_at IS NULL + ORDER BY created_at DESC + LIMIT 20; + " +} + +cmd_bounce() { + local target="${1:?Usage: ses-test.sh bounce [ses_message_id]}" + resolve_target "$target" "${2:-}" + + local inner + inner=$(cat < [args] + +Simulate SNS webhook events: + bounce Simulate a permanent (hard) bounce + bounce-transient Simulate a transient (soft) bounce + complaint Simulate an abuse complaint + delivery Simulate a delivery notification + subscribe Simulate SNS subscription confirmation + +Inspect and manage: + list Show recent outgoing messages (marketing + transaction) + list-suppressions Show email suppression records + unsuppress Remove suppression by id or email + +Target formats: + marketing:10 Look up email + ses_message_id from outgoing_messages row 10 + transaction:2 Look up from outgoing_transaction_messages row 2 + user@example.com Use this email with a generated ses_message_id + user@example.com ID Use this email with a specific ses_message_id + +Status transitions: + delivery: SENT → DELIVERED (only from SENT) + bounce: SENT or DELIVERED → BOUNCED + complaint: SENT or DELIVERED → BOUNCED (also creates suppression) + +Environment: + SES_TEST_URL Webhook URL (default: https://localhost:8443/api/public/webhooks/ses) + +Examples: + sns-webhook-simulator.sh list + sns-webhook-simulator.sh delivery marketing:10 + sns-webhook-simulator.sh bounce marketing:10 + sns-webhook-simulator.sh bounce transaction:1 + sns-webhook-simulator.sh complaint marketing:10 + sns-webhook-simulator.sh bounce user@example.com + sns-webhook-simulator.sh list-suppressions + sns-webhook-simulator.sh unsuppress 3 + sns-webhook-simulator.sh unsuppress bounce@simulator.amazonses.com + +Requires docker/development environment running with: + AWS_SNS_VERIFY_SIGNATURE=false + SES_SUPPRESSION_ENABLED=true +USAGE + ;; +esac diff --git a/frontend/src/api/admin.client.ts b/frontend/src/api/admin.client.ts index 4fde19eac9..75d79b9854 100644 --- a/frontend/src/api/admin.client.ts +++ b/frontend/src/api/admin.client.ts @@ -338,6 +338,36 @@ export interface GetAllAdminMessagesParams { sort_direction?: 'asc' | 'desc'; } +export interface EmailSuppression { + id: IdParam; + email: string; + reason: string; + bounce_type: string | null; + bounce_sub_type: string | null; + complaint_type: string | null; + source: string; + account_id: IdParam | null; + account_name: string | null; + created_at: string; +} + +export interface GetAllEmailSuppressionsParams { + page?: number; + per_page?: number; + search?: string; + reason?: string; + source?: string; + sort_by?: string; + sort_direction?: 'asc' | 'desc'; +} + +export interface CreateEmailSuppressionData { + email: string; + reason: string; + bounce_type?: string | null; + complaint_type?: string | null; +} + export interface LaravelPaginatedData { current_page: number; data: T[]; @@ -568,4 +598,29 @@ export const adminClient = { const response = await api.get>('admin/messaging-tiers'); return response.data; }, + + getAllEmailSuppressions: async (params: GetAllEmailSuppressionsParams = {}) => { + const response = await api.get>('admin/email-suppressions', { + params: { + page: params.page || 1, + per_page: params.per_page || 20, + search: params.search || undefined, + reason: params.reason || undefined, + source: params.source || undefined, + sort_by: params.sort_by || 'created_at', + sort_direction: params.sort_direction || 'desc', + } + }); + return response.data; + }, + + createEmailSuppression: async (data: CreateEmailSuppressionData) => { + const response = await api.post>('admin/email-suppressions', data); + return response.data; + }, + + deleteEmailSuppression: async (id: IdParam) => { + const response = await api.delete(`admin/email-suppressions/${id}`); + return response.data; + }, }; diff --git a/frontend/src/api/delivery-issues.client.ts b/frontend/src/api/delivery-issues.client.ts new file mode 100644 index 0000000000..8992403ec6 --- /dev/null +++ b/frontend/src/api/delivery-issues.client.ts @@ -0,0 +1,24 @@ +import {api} from "./client"; +import {DeliveryIssue, GenericPaginatedResponse, IdParam, QueryFilters} from "../types"; +import {queryParamsHelper} from "../utilites/queryParamsHelper.ts"; +import {AxiosResponse} from "axios"; + +export const deliveryIssuesClient = { + failures: async (eventId: IdParam, pagination: QueryFilters, showResolved = false) => { + const params = queryParamsHelper.buildQueryString(pagination); + const separator = params ? '&' : '?'; + const resolvedParam = showResolved ? `${separator}show_resolved=1` : ''; + const response: AxiosResponse> = await api.get>( + `events/${eventId}/delivery-issues${params}${resolvedParam}`, + ); + return response.data; + }, + + resolve: async (eventId: IdParam, messageId: IdParam, sourceType: 'transaction' | 'announcement') => { + const response = await api.post( + `events/${eventId}/delivery-issues/${messageId}/resolve`, + {source_type: sourceType}, + ); + return response.data; + }, +}; diff --git a/frontend/src/api/outgoing-messages.client.ts b/frontend/src/api/outgoing-messages.client.ts new file mode 100644 index 0000000000..6755ac59d3 --- /dev/null +++ b/frontend/src/api/outgoing-messages.client.ts @@ -0,0 +1,21 @@ +import {api} from "./client"; +import {GenericDataResponse, GenericPaginatedResponse, IdParam, OutgoingMessage, QueryFilters} from "../types"; +import {queryParamsHelper} from "../utilites/queryParamsHelper.ts"; +import {AxiosResponse} from "axios"; + +export const outgoingMessagesClient = { + all: async (eventId: IdParam, pagination: QueryFilters) => { + const response: AxiosResponse> = await api.get>( + `events/${eventId}/outgoing-messages` + queryParamsHelper.buildQueryString(pagination), + ); + return response.data; + }, + + resend: async (eventId: IdParam, messageId: IdParam, email?: string) => { + const response: AxiosResponse> = await api.post( + `events/${eventId}/outgoing-messages/${messageId}/resend`, + email ? {email} : {}, + ); + return response.data; + }, +}; diff --git a/frontend/src/api/transaction-messages.client.ts b/frontend/src/api/transaction-messages.client.ts new file mode 100644 index 0000000000..5fcc251ad9 --- /dev/null +++ b/frontend/src/api/transaction-messages.client.ts @@ -0,0 +1,21 @@ +import {api} from "./client"; +import {GenericDataResponse, GenericPaginatedResponse, IdParam, OutgoingTransactionMessage, QueryFilters} from "../types"; +import {queryParamsHelper} from "../utilites/queryParamsHelper.ts"; +import {AxiosResponse} from "axios"; + +export const transactionMessagesClient = { + all: async (eventId: IdParam, pagination: QueryFilters) => { + const response: AxiosResponse> = await api.get>( + `events/${eventId}/transaction-messages` + queryParamsHelper.buildQueryString(pagination), + ); + return response.data; + }, + + resend: async (eventId: IdParam, messageId: IdParam, email?: string) => { + const response: AxiosResponse> = await api.post( + `events/${eventId}/transaction-messages/${messageId}/resend`, + email ? {email} : {}, + ); + return response.data; + }, +}; diff --git a/frontend/src/components/layouts/Admin/index.tsx b/frontend/src/components/layouts/Admin/index.tsx index 9a6e665c84..78428d76bf 100644 --- a/frontend/src/components/layouts/Admin/index.tsx +++ b/frontend/src/components/layouts/Admin/index.tsx @@ -1,4 +1,4 @@ -import {IconUsers, IconBuildingBank, IconLayoutDashboard, IconCalendar, IconReceipt, IconSettings, IconChartBar, IconAlertTriangle, IconMail} from "@tabler/icons-react"; +import {IconUsers, IconBuildingBank, IconLayoutDashboard, IconCalendar, IconReceipt, IconSettings, IconChartBar, IconAlertTriangle, IconMail, IconMailOff} from "@tabler/icons-react"; import {t} from "@lingui/macro"; import {NavItem, BreadcrumbItem} from "../AppLayout/types"; import AppLayout from "../AppLayout"; @@ -16,6 +16,7 @@ const AdminLayout = () => { {link: 'events', label: t`Events`, icon: IconCalendar}, {link: 'orders', label: t`Orders`, icon: IconReceipt}, {link: 'messages', label: t`Messages`, icon: IconMail}, + {link: 'email-suppressions', label: t`Email Suppressions`, icon: IconMailOff}, {link: 'attribution', label: t`UTM Analytics`, icon: IconChartBar}, {link: 'failed-jobs', label: t`Failed Jobs`, icon: IconAlertTriangle}, {link: 'configurations', label: t`Configurations`, icon: IconSettings}, diff --git a/frontend/src/components/layouts/Event/index.tsx b/frontend/src/components/layouts/Event/index.tsx index 95fef67ec5..e7a3897221 100644 --- a/frontend/src/components/layouts/Event/index.tsx +++ b/frontend/src/components/layouts/Event/index.tsx @@ -118,6 +118,7 @@ const EventLayout = () => { {link: 'attendees', label: t`Attendees`, icon: IconUsers, badge: eventStats?.total_attendees_registered}, {link: 'check-in', label: t`Check-In Lists`, icon: IconQrcode}, {link: 'messages', label: t`Messages`, icon: IconSend}, + {link: 'message-tracking', label: t`Message Tracking`, icon: IconMailCheck}, {link: 'sold-out-waitlist', label: t`Waitlist`, icon: IconListCheck}, {link: 'capacity-assignments', label: t`Capacity Management`, icon: IconUsersGroup}, diff --git a/frontend/src/components/modals/MessageRecipientsModal/index.tsx b/frontend/src/components/modals/MessageRecipientsModal/index.tsx index 2111aac564..18d9998605 100644 --- a/frontend/src/components/modals/MessageRecipientsModal/index.tsx +++ b/frontend/src/components/modals/MessageRecipientsModal/index.tsx @@ -15,10 +15,16 @@ interface MessageRecipientsModalProps extends GenericModalProps { const statusColor = (status: string) => { switch (status?.toUpperCase()) { - case 'SENT': + case 'DELIVERED': return 'green'; - case 'FAILED': + case 'SENT': + return 'teal'; + case 'BOUNCED': return 'red'; + case 'FAILED': + return 'orange'; + case 'SUPPRESSED': + return 'gray'; default: return 'gray'; } diff --git a/frontend/src/components/modals/ResolveDeliveryIssueModal/index.tsx b/frontend/src/components/modals/ResolveDeliveryIssueModal/index.tsx new file mode 100644 index 0000000000..30eb4bce88 --- /dev/null +++ b/frontend/src/components/modals/ResolveDeliveryIssueModal/index.tsx @@ -0,0 +1,111 @@ +import {useForm} from "@mantine/form"; +import {GenericModalProps, IdParam, DeliveryIssue} from "../../../types.ts"; +import {Modal} from "../../common/Modal"; +import {Button, Checkbox, FocusTrap, Group, TextInput} from "@mantine/core"; +import {useFormErrorResponseHandler} from "../../../hooks/useFormErrorResponseHandler.tsx"; +import {t} from "@lingui/macro"; +import {showSuccess, showError} from "../../../utilites/notifications.tsx"; +import {useResendTransactionMessage} from "../../../mutations/useResendTransactionMessage.ts"; +import {useResendOutgoingMessage} from "../../../mutations/useResendOutgoingMessage.ts"; + +interface ResolveDeliveryIssueModalProps extends GenericModalProps { + eventId: IdParam; + message: DeliveryIssue; +} + +export const ResolveDeliveryIssueModal = ({onClose, eventId, message}: ResolveDeliveryIssueModalProps) => { + const isTransaction = message.source_type === 'transaction'; + const resendTransactionMutation = useResendTransactionMessage(); + const resendAnnouncementMutation = useResendOutgoingMessage(); + const formErrorHandler = useFormErrorResponseHandler(); + + const form = useForm({ + initialValues: { + email: message.recipient, + resend: true, + }, + }); + + const emailChanged = form.values.email.toLowerCase() !== message.recipient.toLowerCase(); + const isPending = isTransaction ? resendTransactionMutation.isPending : resendAnnouncementMutation.isPending; + + const handleEmailChange = (e: React.ChangeEvent) => { + form.getInputProps('email').onChange(e); + const newEmail = e.currentTarget.value; + const changed = newEmail.toLowerCase() !== message.recipient.toLowerCase(); + if (changed && !form.values.resend) { + form.setFieldValue('resend', true); + } + }; + + const handleSubmit = (values: { email: string, resend: boolean }) => { + if (!values.resend) { + onClose(); + return; + } + + const mutate = isTransaction + ? resendTransactionMutation.mutate + : resendAnnouncementMutation.mutate; + + mutate({ + eventId, + messageId: message.id!, + email: emailChanged ? values.email : undefined, + }, { + onSuccess: () => { + showSuccess(t`Email has been resent. The issue will be auto-resolved.`); + onClose(); + }, + onError: (error: any) => { + formErrorHandler(form, error); + showError(t`Failed to resend email. Please try again.`); + }, + }); + }; + + return ( + + +
+ + + + + + + + + +
+
+ ); +}; diff --git a/frontend/src/components/routes/admin/EmailSuppressions/index.tsx b/frontend/src/components/routes/admin/EmailSuppressions/index.tsx new file mode 100644 index 0000000000..f1b4fb50b9 --- /dev/null +++ b/frontend/src/components/routes/admin/EmailSuppressions/index.tsx @@ -0,0 +1,295 @@ +import {Container, Title, TextInput, Skeleton, Pagination, Stack, Table, Text, Badge, Group, Select, Modal, ActionIcon, Tooltip, Button} from "@mantine/core"; +import {t} from "@lingui/macro"; +import {IconSearch, IconMailOff, IconTrash, IconPlus} from "@tabler/icons-react"; +import {useState, useEffect} from "react"; +import {useGetEmailSuppressions} from "../../../../queries/useGetEmailSuppressions"; +import {useDeleteEmailSuppression} from "../../../../mutations/useDeleteEmailSuppression"; +import {useCreateEmailSuppression} from "../../../../mutations/useCreateEmailSuppression"; +import {relativeDate} from "../../../../utilites/dates"; +import {useDisclosure} from "@mantine/hooks"; +import {showSuccess, showError} from "../../../../utilites/notifications"; +import {confirmationDialog} from "../../../../utilites/confirmationDialog"; +import {IdParam} from "../../../../types"; +import {useForm} from "@mantine/form"; +import tableStyles from "../../../../styles/admin-table.module.scss"; + +const EmailSuppressions = () => { + const [page, setPage] = useState(1); + const [search, setSearch] = useState(""); + const [debouncedSearch, setDebouncedSearch] = useState(""); + const [reasonFilter, setReasonFilter] = useState(null); + const [sourceFilter, setSourceFilter] = useState(null); + const [createModalOpened, {open: openCreateModal, close: closeCreateModal}] = useDisclosure(false); + + const {data: suppressionsData, isLoading} = useGetEmailSuppressions({ + page, + per_page: 20, + search: debouncedSearch, + reason: reasonFilter || undefined, + source: sourceFilter || undefined, + }); + + const deleteMutation = useDeleteEmailSuppression(); + const createMutation = useCreateEmailSuppression(); + + const form = useForm({ + initialValues: { + email: '', + reason: 'bounce', + bounce_type: 'Permanent', + }, + validate: { + email: (value) => (!value || !value.includes('@') ? t`Please enter a valid email address` : null), + reason: (value) => (!value ? t`Reason is required` : null), + }, + }); + + const handleDelete = (id: IdParam) => { + confirmationDialog( + t`Are you sure you want to remove this suppression? The email address will be able to receive messages again.`, + () => { + deleteMutation.mutate({id}, { + onSuccess: () => showSuccess(t`Suppression removed successfully`), + onError: () => showError(t`Failed to remove suppression`), + }); + }, + ); + }; + + const handleCreate = (values: typeof form.values) => { + createMutation.mutate({ + email: values.email, + reason: values.reason, + bounce_type: values.reason === 'bounce' ? values.bounce_type : undefined, + }, { + onSuccess: () => { + showSuccess(t`Email suppression added successfully`); + form.reset(); + closeCreateModal(); + }, + onError: () => showError(t`Failed to add suppression`), + }); + }; + + useEffect(() => { + const timer = setTimeout(() => { + setDebouncedSearch(search); + setPage(1); + }, 500); + + return () => clearTimeout(timer); + }, [search]); + + const getReasonColor = (reason: string) => { + switch (reason.toLowerCase()) { + case 'bounce': + return 'red'; + case 'complaint': + return 'orange'; + default: + return 'gray'; + } + }; + + const getSourceColor = (source: string) => { + switch (source.toLowerCase()) { + case 'ses_notification': + return 'blue'; + case 'manual': + return 'gray'; + default: + return 'gray'; + } + }; + + const getSourceLabel = (source: string) => { + switch (source.toLowerCase()) { + case 'ses_notification': + return t`SES`; + case 'manual': + return t`Manual`; + default: + return source; + } + }; + + const totalSuppressions = suppressionsData?.meta?.total || 0; + + return ( + + + +
+ {t`Email Suppressions`} + {t`Manage bounced and suppressed email addresses`} +
+ +
+ + + } + value={search} + onChange={(e) => setSearch(e.target.value)} + style={{flex: 1}} + /> + + + + {isLoading ? ( + + + + + ) : totalSuppressions === 0 ? ( +
+ + {t`No suppressed emails found`} +
+ ) : ( +
+
+ + + + {t`Email`} + {t`Reason`} + {t`Type`} + {t`Source`} + {t`Account`} + {t`Date`} + + + + + {suppressionsData?.data?.map((suppression) => ( + + + + + + {suppression.email} + + + + + + {suppression.reason} + + + + + {suppression.bounce_type || suppression.complaint_type || '-'} + + + + + {getSourceLabel(suppression.source)} + + + + + {suppression.account_name || '-'} + + + + {relativeDate(suppression.created_at)} + + + + handleDelete(suppression.id)} + loading={deleteMutation.isPending} + > + + + + + + ))} + +
+
+
+ )} + + {suppressionsData?.meta && suppressionsData.meta.last_page > 1 && ( + + )} +
+ + +
+ + + + )} + + +
+
+
+ ); +}; + +export default EmailSuppressions; diff --git a/frontend/src/components/routes/event/DeliveryIssues/DeliveryIssues.module.scss b/frontend/src/components/routes/event/DeliveryIssues/DeliveryIssues.module.scss new file mode 100644 index 0000000000..b5ca058e96 --- /dev/null +++ b/frontend/src/components/routes/event/DeliveryIssues/DeliveryIssues.module.scss @@ -0,0 +1,8 @@ +.emptyState { + text-align: center; + padding: 40px 20px; +} + +.emailTypeLabel { + text-transform: capitalize; +} diff --git a/frontend/src/components/routes/event/DeliveryIssues/index.tsx b/frontend/src/components/routes/event/DeliveryIssues/index.tsx new file mode 100644 index 0000000000..041810f2bd --- /dev/null +++ b/frontend/src/components/routes/event/DeliveryIssues/index.tsx @@ -0,0 +1,173 @@ +import {PageTitle} from "../../../common/PageTitle"; +import {t} from "@lingui/macro"; +import {PageBody} from "../../../common/PageBody"; +import {ActionIcon, Alert, Badge, Button, Group, Switch, Table, Text, Tooltip} from "@mantine/core"; +import {Card} from "../../../common/Card"; +import {useParams} from "react-router"; +import {useGetDeliveryIssues} from "../../../../queries/useGetDeliveryIssues.ts"; +import {relativeDate} from "../../../../utilites/dates.ts"; +import {Pagination} from "../../../common/Pagination"; +import {useState} from "react"; +import {TableSkeleton} from "../../../common/TableSkeleton"; +import {IconCheck, IconCircleDashed} from "@tabler/icons-react"; +import {useResolveDeliveryIssue} from "../../../../mutations/useResolveDeliveryIssue.ts"; +import {ResolveDeliveryIssueModal} from "../../../modals/ResolveDeliveryIssueModal"; +import {DeliveryIssue} from "../../../../types.ts"; +import {showError} from "../../../../utilites/notifications.tsx"; +import {statusColor, emailTypeLabel} from "../MessageTracking/shared.tsx"; +import classes from "./DeliveryIssues.module.scss"; + +const DeliveryIssues = () => { + const {eventId} = useParams(); + const [page, setPage] = useState(1); + const [showResolved, setShowResolved] = useState(false); + const [resolveModalMessage, setResolveModalMessage] = useState(null); + const issuesQuery = useGetDeliveryIssues(eventId, {pageNumber: page, perPage: 20}, showResolved); + const issues = issuesQuery.data?.data; + const pagination = issuesQuery.data?.meta; + const resolveMutation = useResolveDeliveryIssue(); + + const handleToggleResolved = (issue: DeliveryIssue) => { + resolveMutation.mutate({ + eventId: eventId!, + messageId: issue.id!, + sourceType: issue.source_type, + }, { + onError: () => showError(t`Failed to update resolved status.`), + }); + }; + + const sourceTypeLabel = (issue: DeliveryIssue) => { + const label = issue.source_type === 'announcement' + ? t`Announcement` + : emailTypeLabel(issue.email_type); + return issue.retry_for_id ? `${label} · ${t`Retry`}` : label; + }; + + return ( + + + {t`Delivery Issues`} + + + {issuesQuery.isLoading && ( + + + + )} + + {!!issuesQuery.error && ( + + {t`Failed to load delivery issues`} + + )} + + {!issuesQuery.isLoading && !issuesQuery.error && ( + + + { + setShowResolved(e.currentTarget.checked); + setPage(1); + }} + /> + + + {issues && issues.length === 0 && ( +
+ + {showResolved + ? t`No delivery issues found.` + : t`No unresolved delivery issues.` + } + +
+ )} + + {issues && issues.length > 0 && ( + <> + + + + {t`Resolved`} + {t`Type`} + {t`Status`} + {t`Subject`} + {t`Recipient`} + {t`Date`} + + + + + {issues.map((issue) => ( + + + + handleToggleResolved(issue)} + loading={resolveMutation.isPending} + > + {issue.resolved_at + ? + : + } + + + + {sourceTypeLabel(issue)} + + + {issue.status} + + + {issue.subject} + {issue.recipient} + {relativeDate(issue.updated_at)} + + {!issue.resolved_at && !issue.retry_for_id && ( + + )} + + + ))} + +
+ + {pagination && Number(pagination.last_page) > 1 && ( + + )} + + )} +
+ )} + + {resolveModalMessage && ( + setResolveModalMessage(null)} + eventId={eventId!} + message={resolveModalMessage} + /> + )} +
+ ); +}; + +export default DeliveryIssues; diff --git a/frontend/src/components/routes/event/MessageTracking/MarketingTab.tsx b/frontend/src/components/routes/event/MessageTracking/MarketingTab.tsx new file mode 100644 index 0000000000..0db50835fa --- /dev/null +++ b/frontend/src/components/routes/event/MessageTracking/MarketingTab.tsx @@ -0,0 +1,308 @@ +import {t} from "@lingui/macro"; +import {ActionIcon, Alert, Badge, Button, Group, SegmentedControl, Select, Table, Text, TextInput, Tooltip, UnstyledButton} from "@mantine/core"; +import {Card} from "../../../common/Card"; +import {useParams} from "react-router"; +import {useGetOutgoingMessages, GET_OUTGOING_MESSAGES_QUERY_KEY} from "../../../../queries/useGetOutgoingMessages.ts"; +import {relativeDate} from "../../../../utilites/dates.ts"; +import {Pagination} from "../../../common/Pagination"; +import {useState} from "react"; +import {TableSkeleton} from "../../../common/TableSkeleton"; +import {DeliveryIssue, OutgoingMessage, QueryFilterOperator} from "../../../../types.ts"; +import {IconCheck, IconCircleDashed, IconSearch, IconSortAscending, IconSortDescending, IconUserCheck} from "@tabler/icons-react"; +import {statusColor, statusFilterOptions, dateRangeOptions, ResolvedHoverCard, RetryHoverCard} from "./shared.tsx"; +import {useResolveDeliveryIssue} from "../../../../mutations/useResolveDeliveryIssue.ts"; +import {ResolveDeliveryIssueModal} from "../../../modals/ResolveDeliveryIssueModal"; +import {showError} from "../../../../utilites/notifications.tsx"; +import {useQueryClient} from "@tanstack/react-query"; + +const FAILED_STATUSES = ['BOUNCED', 'FAILED', 'SUPPRESSED']; + +const toDeliveryIssue = (msg: OutgoingMessage): DeliveryIssue => ({ + id: msg.id!, + source_type: 'announcement', + email_type: 'announcement', + status: msg.status, + subject: msg.subject, + recipient: msg.recipient, + updated_at: msg.created_at || '', + resolved_at: msg.resolved_at || null, + retry_for_id: msg.retry_for_id || null, +}); + +interface SortableThProps { + label: string; + field: string; + sortBy: string; + sortDir: string; + onSort: (field: string) => void; +} + +const SortableTh = ({label, field, sortBy, sortDir, onSort}: SortableThProps) => { + const isActive = sortBy === field; + const Icon = isActive && sortDir === 'asc' ? IconSortAscending : IconSortDescending; + + return ( + + onSort(field)} style={{display: 'flex', alignItems: 'center', gap: 4, fontWeight: 700}}> + {label} + {isActive && } + + + ); +}; + +export const MarketingTab = () => { + const {eventId} = useParams(); + const [page, setPage] = useState(1); + const [query, setQuery] = useState(''); + const [statusFilter, setStatusFilter] = useState(null); + const [dateRange, setDateRange] = useState('30d'); + const [sortBy, setSortBy] = useState('created_at'); + const [sortDir, setSortDir] = useState('desc'); + const [resolveModalMessage, setResolveModalMessage] = useState(null); + const [resolvingId, setResolvingId] = useState(null); + const resolveMutation = useResolveDeliveryIssue(); + const queryClient = useQueryClient(); + + const isIssuesFilter = dateRange === 'issues'; + + const handleSort = (field: string) => { + if (sortBy === field) { + setSortDir(sortDir === 'asc' ? 'desc' : 'asc'); + } else { + setSortBy(field); + setSortDir('asc'); + } + setPage(1); + }; + + const filterFields: Record = {}; + if (isIssuesFilter) { + filterFields.status = {operator: QueryFilterOperator.In, value: FAILED_STATUSES.join(',')}; + } else { + if (statusFilter) { + filterFields.status = {operator: QueryFilterOperator.In, value: statusFilter}; + } + if (dateRange !== 'all') { + filterFields.date_range = {operator: QueryFilterOperator.Equals, value: dateRange}; + } + } + + const searchParams: any = { + pageNumber: page, + perPage: 20, + query: query || undefined, + filterFields: Object.keys(filterFields).length > 0 ? filterFields : undefined, + sortBy, + sortDirection: sortDir, + }; + + const messagesQuery = useGetOutgoingMessages(eventId, searchParams); + const messages = messagesQuery.data?.data; + const pagination = messagesQuery.data?.meta; + + const handleToggleResolved = (msg: OutgoingMessage) => { + setResolvingId(msg.id!); + resolveMutation.mutate({ + eventId: eventId!, + messageId: msg.id!, + sourceType: 'announcement', + }, { + onSuccess: () => { + queryClient.invalidateQueries({queryKey: [GET_OUTGOING_MESSAGES_QUERY_KEY]}); + setResolvingId(null); + }, + onError: () => { + showError(t`Failed to update resolved status.`); + setResolvingId(null); + }, + }); + }; + + const isFailed = (status: string) => FAILED_STATUSES.includes(status.toUpperCase()); + + const getResolvedIcon = (msg: OutgoingMessage) => { + if (!isFailed(msg.status)) return null; + + if (!msg.resolved_at) { + return ( + + handleToggleResolved(msg)} loading={resolvingId === msg.id}> + + + + ); + } + + const isAuto = msg.resolution_type === 'auto'; + const isManual = msg.resolution_type === 'manual'; + const icon = ( + handleToggleResolved(msg)} + loading={resolvingId === msg.id} + > + {isManual ? : } + + ); + + return {icon}; + }; + + const getStatusBadge = (msg: OutgoingMessage) => { + if (msg.resolved_at) { + return ( + + + {t`RESOLVED`} + + + ); + } + const badge = ( + + {msg.status} + + ); + if (msg.retry_for_id) { + return {badge}; + } + return badge; + }; + + const getActionButton = (msg: OutgoingMessage) => { + if (!isFailed(msg.status)) return null; + if (msg.retry_for_id) return null; + if (msg.resolved_at) return null; + + const label = (msg.retry_count ?? 0) > 0 ? t`Retry` : t`Resolve`; + + return ( + + ); + }; + + return ( + <> +
+ + { setEmailTypeFilter(val); setPage(1); }} + clearable + size="sm" + style={{width: 180, marginBottom: 0}} + /> +