diff --git a/flowbit-backend/core/migrations/0025_supportcase_requester_email.py b/flowbit-backend/core/migrations/0025_supportcase_requester_email.py new file mode 100644 index 0000000..deff96e --- /dev/null +++ b/flowbit-backend/core/migrations/0025_supportcase_requester_email.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.28 on 2026-05-29 11:14 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("core", "0024_alter_supportcase_options_overrideresettoken"), + ] + + operations = [ + migrations.AddField( + model_name="supportcase", + name="requester_email", + field=models.EmailField(blank=True, default="", max_length=254), + ), + ] diff --git a/flowbit-backend/core/models.py b/flowbit-backend/core/models.py index 939f1fe..dafe50a 100644 --- a/flowbit-backend/core/models.py +++ b/flowbit-backend/core/models.py @@ -1732,6 +1732,7 @@ class SupportCase(models.Model): subject = models.CharField(max_length=160) intake_type = models.CharField(max_length=24, choices=INTAKE_TYPE_CHOICES, default=INTAKE_STANDARD) requester_name = models.CharField(max_length=160, blank=True, default='') + requester_email = models.EmailField(max_length=254, blank=True, default='') requester_login_identifier = models.CharField(max_length=160, blank=True, default='') status = models.CharField(max_length=16, choices=STATUS_CHOICES, default=STATUS_OPEN) closed_at = models.DateTimeField(null=True, blank=True) diff --git a/flowbit-backend/core/serializers.py b/flowbit-backend/core/serializers.py index 781f73d..5410cac 100644 --- a/flowbit-backend/core/serializers.py +++ b/flowbit-backend/core/serializers.py @@ -1161,6 +1161,7 @@ class Meta: 'subject', 'intake_type', 'requester_name', + 'requester_email', 'requester_login_identifier', 'status', 'created_by', @@ -1225,6 +1226,7 @@ def validate_message(self, value): class PublicLoginHelpCaseCreateSerializer(serializers.Serializer): login_identifier = serializers.CharField(max_length=160) requester_name = serializers.CharField(max_length=160, required=False, allow_blank=True) + requester_email = serializers.EmailField(max_length=254) subject = serializers.CharField(max_length=160) message = serializers.CharField() @@ -1237,6 +1239,9 @@ def validate_login_identifier(self, value): def validate_requester_name(self, value): return value.strip() + def validate_requester_email(self, value): + return value.strip().lower() + def validate_subject(self, value): value = value.strip() if not value: @@ -1252,6 +1257,7 @@ def validate_message(self, value): class SupportCaseReplySerializer(serializers.Serializer): message = serializers.CharField() + requester_email = serializers.EmailField(required=False, allow_blank=True, max_length=254) def validate_message(self, value): value = value.strip() @@ -1259,6 +1265,9 @@ def validate_message(self, value): raise serializers.ValidationError('Message is required.') return value + def validate_requester_email(self, value): + return value.strip().lower() + class SupportCaseDetailSerializer(SupportCaseSerializer): messages = SupportMessageSerializer(many=True, read_only=True) diff --git a/flowbit-backend/core/tests.py b/flowbit-backend/core/tests.py index 13513cb..f223be8 100644 --- a/flowbit-backend/core/tests.py +++ b/flowbit-backend/core/tests.py @@ -2674,6 +2674,7 @@ def test_public_login_help_case_can_be_created_without_authentication(self): response = self.client.post('/api/support-cases/login-help/', { 'login_identifier': 'locked.user', 'requester_name': 'Locked User', + 'requester_email': 'locked.user@example.com', 'subject': 'Cannot log in', 'message': 'I cannot access my account after several attempts.', }, format='json') @@ -2683,6 +2684,7 @@ def test_public_login_help_case_can_be_created_without_authentication(self): support_case = SupportCase.objects.get(subject='Cannot log in') self.assertEqual(support_case.intake_type, SupportCase.INTAKE_LOGIN_HELP) self.assertEqual(support_case.requester_name, 'Locked User') + self.assertEqual(support_case.requester_email, 'locked.user@example.com') self.assertEqual(support_case.requester_login_identifier, 'locked.user') self.assertEqual(support_case.created_by.username, '_login_help_intake') self.assertEqual(support_case.messages.count(), 1) @@ -2707,6 +2709,7 @@ def test_admin_case_detail_shows_requester_identity_for_login_help_message(self) self.client.post('/api/support-cases/login-help/', { 'login_identifier': 'locked.user', 'requester_name': 'Locked User', + 'requester_email': 'locked.user@example.com', 'subject': 'Cannot log in', 'message': 'I cannot access my account after several attempts.', }, format='json') @@ -2718,9 +2721,93 @@ def test_admin_case_detail_shows_requester_identity_for_login_help_message(self) self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(response.data['created_by_full_name'], 'Locked User') self.assertEqual(response.data['created_by_username'], 'locked.user') + self.assertEqual(response.data['requester_email'], 'locked.user@example.com') self.assertEqual(response.data['messages'][0]['sender_full_name'], 'Locked User') self.assertEqual(response.data['messages'][0]['sender_username'], 'locked.user') + def test_admin_reply_to_login_help_case_sends_email(self): + self.client.post('/api/support-cases/login-help/', { + 'login_identifier': 'locked.user', + 'requester_name': 'Locked User', + 'requester_email': 'locked.user@example.com', + 'subject': 'Cannot log in', + 'message': 'I cannot access my account after several attempts.', + }, format='json') + support_case = SupportCase.objects.get(subject='Cannot log in') + + self.client.force_authenticate(user=self.admin_user) + response = self.client.post( + f'/api/support-cases/{support_case.id}/reply/', + {'message': 'Please try signing in again now.'}, + format='json', + ) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(len(mail.outbox), 1) + self.assertEqual(mail.outbox[0].to, ['locked.user@example.com']) + self.assertIn('Please try signing in again now.', mail.outbox[0].body) + + def test_admin_reply_to_old_login_help_case_can_save_reply_email(self): + intake_user = User.objects.create_user(username='_old_login_help_intake') + support_case = SupportCase.objects.create( + created_by=intake_user, + subject='Old login help', + intake_type=SupportCase.INTAKE_LOGIN_HELP, + requester_name='Old User', + requester_login_identifier='old.user', + last_message_at=timezone.now(), + ) + SupportMessage.objects.create( + support_case=support_case, + sender=intake_user, + body='I need help signing in.', + ) + + self.client.force_authenticate(user=self.admin_user) + response = self.client.post( + f'/api/support-cases/{support_case.id}/reply/', + { + 'message': 'Please try signing in again now.', + 'requester_email': 'Old.User@Example.com', + }, + format='json', + ) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + support_case.refresh_from_db() + self.assertEqual(support_case.requester_email, 'old.user@example.com') + self.assertEqual(len(mail.outbox), 1) + self.assertEqual(mail.outbox[0].to, ['old.user@example.com']) + + def test_admin_reply_to_old_login_help_case_uses_email_login_identifier(self): + intake_user = User.objects.create_user(username='_email_login_help_intake') + support_case = SupportCase.objects.create( + created_by=intake_user, + subject='Email login help', + intake_type=SupportCase.INTAKE_LOGIN_HELP, + requester_name='Email User', + requester_login_identifier='email.user@example.com', + last_message_at=timezone.now(), + ) + SupportMessage.objects.create( + support_case=support_case, + sender=intake_user, + body='I need help signing in.', + ) + + self.client.force_authenticate(user=self.admin_user) + response = self.client.post( + f'/api/support-cases/{support_case.id}/reply/', + {'message': 'Please try signing in again now.'}, + format='json', + ) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + support_case.refresh_from_db() + self.assertEqual(support_case.requester_email, 'email.user@example.com') + self.assertEqual(len(mail.outbox), 1) + self.assertEqual(mail.outbox[0].to, ['email.user@example.com']) + def test_case_can_be_replied_closed_and_reopened_by_both_sides(self): support_case = SupportCase.objects.create( created_by=self.user_one, diff --git a/flowbit-backend/core/views.py b/flowbit-backend/core/views.py index b8b29c7..3c44ace 100644 --- a/flowbit-backend/core/views.py +++ b/flowbit-backend/core/views.py @@ -271,6 +271,10 @@ class AuthEmailDeliveryError(Exception): pass +class SupportEmailDeliveryError(Exception): + pass + + def send_auth_email(*, request, user, subject, message, audit_action): try: send_mail( @@ -292,6 +296,42 @@ def send_auth_email(*, request, user, subject, message, audit_action): raise AuthEmailDeliveryError from exc +def send_support_reply_email(*, request, support_case, recipient_email, subject, message): + try: + send_mail( + subject=subject, + message=message, + from_email=getattr(settings, 'DEFAULT_FROM_EMAIL', 'noreply@flowbit.local'), + recipient_list=[recipient_email], + fail_silently=False, + ) + except Exception as exc: + logger.exception("Failed to send support reply email to %s for case %s", recipient_email, support_case.id) + record_audit_log( + request, + 'support.email_delivery_failed', + target=support_case, + details=f"Support reply email delivery failed for case '{support_case.subject}'", + changes={'case_id': support_case.id, 'recipient_email': recipient_email, 'error': str(exc)}, + ) + raise SupportEmailDeliveryError from exc + + +def build_login_help_reply_email_body(*, support_case, admin_user, reply_body): + admin_name = admin_user.get_full_name().strip() or admin_user.username + return "\n".join( + [ + f"FlowBit login help reply: {support_case.subject}", + "", + f"Admin: {admin_name}", + f"Reply sent at: {timezone.localtime(timezone.now()).isoformat()}", + "", + "Reply:", + reply_body, + ] + ) + + def issue_and_send_email_verification(*, request, user): expiry_hours = getattr(settings, 'EMAIL_VERIFICATION_TOKEN_EXPIRY_HOURS', 24) verification_token, raw_token = EmailVerificationToken.issue_for_user(user, expiry_hours=expiry_hours) @@ -3686,15 +3726,56 @@ def reply(self, request, pk=None): support_case = self.get_object() serializer = self.get_serializer(data=request.data) serializer.is_valid(raise_exception=True) + reply_body = serializer.validated_data['message'] + should_save_requester_email = False + + if is_admin_user(request.user) and support_case.intake_type == SupportCase.INTAKE_LOGIN_HELP: + requester_email = ( + support_case.requester_email + or serializer.validated_data.get('requester_email', '') + or ( + support_case.requester_login_identifier + if '@' in support_case.requester_login_identifier + else '' + ) + ) + if not requester_email: + return Response( + {'detail': 'Enter a requester email before sending this login-help reply.'}, + status=status.HTTP_400_BAD_REQUEST, + ) + try: + send_support_reply_email( + request=request, + support_case=support_case, + recipient_email=requester_email, + subject=f"FlowBit login help reply: {support_case.subject}", + message=build_login_help_reply_email_body( + support_case=support_case, + admin_user=request.user, + reply_body=reply_body, + ), + ) + except SupportEmailDeliveryError: + return Response( + {'detail': 'We could not send the login-help reply email right now. Please try again shortly.'}, + status=status.HTTP_503_SERVICE_UNAVAILABLE, + ) + if support_case.requester_email != requester_email: + support_case.requester_email = requester_email + should_save_requester_email = True with db_transaction.atomic(): message = SupportMessage.objects.create( support_case=support_case, sender=request.user, - body=serializer.validated_data['message'], + body=reply_body, ) support_case.last_message_at = message.created_at - support_case.save(update_fields=['last_message_at', 'updated_at']) + update_fields = ['last_message_at', 'updated_at'] + if should_save_requester_email: + update_fields.append('requester_email') + support_case.save(update_fields=update_fields) actor_label = notification_actor_label(request.user) if is_admin_user(request.user): @@ -3788,6 +3869,7 @@ def post(self, request): subject=serializer.validated_data['subject'], intake_type=SupportCase.INTAKE_LOGIN_HELP, requester_name=serializer.validated_data.get('requester_name', ''), + requester_email=serializer.validated_data['requester_email'], requester_login_identifier=serializer.validated_data['login_identifier'], last_message_at=timezone.now(), ) @@ -3825,6 +3907,7 @@ def post(self, request): 'case_id': support_case.id, 'subject': support_case.subject, 'requester_name': support_case.requester_name, + 'requester_email': support_case.requester_email, 'requester_login_identifier': support_case.requester_login_identifier, }, ) diff --git a/flowbit-frontend/src/components/auth/login-help-form-card.tsx b/flowbit-frontend/src/components/auth/login-help-form-card.tsx index a594a86..d29ad81 100644 --- a/flowbit-frontend/src/components/auth/login-help-form-card.tsx +++ b/flowbit-frontend/src/components/auth/login-help-form-card.tsx @@ -19,6 +19,7 @@ export function LoginHelpFormCard() { const [formValues, setFormValues] = useState({ login_identifier: "", requester_name: "", + requester_email: "", subject: "", message: "", }); @@ -36,6 +37,11 @@ export function LoginHelpFormCard() { if (!formValues.subject.trim()) { nextErrors.subject = "Enter a short issue summary."; } + if (!formValues.requester_email.trim()) { + nextErrors.requester_email = "Enter the email address you want the admin to reply to."; + } else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formValues.requester_email.trim())) { + nextErrors.requester_email = "Enter a valid email address."; + } if (!formValues.message.trim()) { nextErrors.message = "Describe the login problem."; } @@ -56,6 +62,7 @@ export function LoginHelpFormCard() { const response = await createPublicLoginHelpCase({ login_identifier: formValues.login_identifier.trim(), requester_name: formValues.requester_name.trim(), + requester_email: formValues.requester_email.trim(), subject: formValues.subject.trim(), message: formValues.message.trim(), }); @@ -63,6 +70,7 @@ export function LoginHelpFormCard() { setFormValues({ login_identifier: "", requester_name: "", + requester_email: "", subject: "", message: "", }); @@ -121,6 +129,19 @@ export function LoginHelpFormCard() { setFieldErrors((current) => ({ ...current, requester_name: undefined })); }} /> + { + setFormValues((current) => ({ ...current, requester_email: event.target.value })); + setFieldErrors((current) => ({ ...current, requester_email: undefined })); + }} + />
diff --git a/flowbit-frontend/src/components/support/customer-service-page.tsx b/flowbit-frontend/src/components/support/customer-service-page.tsx index d677315..4c4b767 100644 --- a/flowbit-frontend/src/components/support/customer-service-page.tsx +++ b/flowbit-frontend/src/components/support/customer-service-page.tsx @@ -51,6 +51,10 @@ function intakeTone(intakeType: "STANDARD" | "LOGIN_HELP") { : "bg-stone-200 text-stone-600"; } +function looksLikeEmail(value: string) { + return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value.trim()); +} + export function CustomerServicePage() { const currentUserState = useCurrentUserState(); const [user, setUser] = useState(getStoredUser()); @@ -63,6 +67,7 @@ export function CustomerServicePage() { const [statusFilter, setStatusFilter] = useState("ALL"); const [search, setSearch] = useState(""); const [replyDraft, setReplyDraft] = useState(""); + const [loginHelpReplyEmail, setLoginHelpReplyEmail] = useState(""); const [newCaseOpen, setNewCaseOpen] = useState(false); const [newCaseSubject, setNewCaseSubject] = useState(""); const [newCaseMessage, setNewCaseMessage] = useState(""); @@ -209,6 +214,17 @@ export function CustomerServicePage() { } }, [selectedCase?.messages.length, selectedCase]); + useEffect(() => { + if (!selectedCase || selectedCase.intake_type !== "LOGIN_HELP") { + setLoginHelpReplyEmail(""); + return; + } + setLoginHelpReplyEmail( + selectedCase.requester_email || + (looksLikeEmail(selectedCase.requester_login_identifier) ? selectedCase.requester_login_identifier : ""), + ); + }, [selectedCase?.id, selectedCase?.intake_type, selectedCase?.requester_email, selectedCase?.requester_login_identifier]); + const filteredCases = useMemo(() => { const normalizedSearch = search.trim().toLowerCase(); return cases.filter((item) => { @@ -258,11 +274,24 @@ export function CustomerServicePage() { if (!selectedCaseId || !replyDraft.trim()) { return; } + const replyEmail = loginHelpReplyEmail.trim(); + if (isAdmin && selectedCase?.intake_type === "LOGIN_HELP" && !replyEmail) { + setErrorMessage("Enter a requester email before sending this login-help reply."); + return; + } + if (isAdmin && selectedCase?.intake_type === "LOGIN_HELP" && replyEmail && !looksLikeEmail(replyEmail)) { + setErrorMessage("Enter a valid requester email before sending this login-help reply."); + return; + } setIsSaving(true); setErrorMessage(""); try { - await replyToSupportCase(selectedCaseId, replyDraft); + await replyToSupportCase( + selectedCaseId, + replyDraft, + isAdmin && selectedCase?.intake_type === "LOGIN_HELP" ? { requester_email: replyEmail } : undefined, + ); setReplyDraft(""); shouldForceScrollRef.current = true; await refreshCasesAndSelectedCase(selectedCaseId); @@ -445,7 +474,10 @@ export function CustomerServicePage() {
{selectedCase.created_by_full_name} {selectedCase.intake_type === "LOGIN_HELP" ? ( - Login: {selectedCase.requester_login_identifier} + <> + Login: {selectedCase.requester_login_identifier} + Email: {selectedCase.requester_email || loginHelpReplyEmail || "Not set"} + ) : null} {formatDateTime(selectedCase.created_at)} {selectedCase.closed_at ? Closed {formatDateTime(selectedCase.closed_at)} : null} @@ -519,6 +551,21 @@ export function CustomerServicePage() {
+ {isAdmin && selectedCase.intake_type === "LOGIN_HELP" ? ( + + ) : null}