Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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),
),
]
1 change: 1 addition & 0 deletions flowbit-backend/core/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
9 changes: 9 additions & 0 deletions flowbit-backend/core/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -1161,6 +1161,7 @@ class Meta:
'subject',
'intake_type',
'requester_name',
'requester_email',
'requester_login_identifier',
'status',
'created_by',
Expand Down Expand Up @@ -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()

Expand All @@ -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:
Expand All @@ -1252,13 +1257,17 @@ 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()
if not 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)
Expand Down
87 changes: 87 additions & 0 deletions flowbit-backend/core/tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand All @@ -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)
Expand All @@ -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')
Expand All @@ -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,
Expand Down
87 changes: 85 additions & 2 deletions flowbit-backend/core/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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)
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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(),
)
Expand Down Expand Up @@ -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,
},
)
Expand Down
21 changes: 21 additions & 0 deletions flowbit-frontend/src/components/auth/login-help-form-card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ export function LoginHelpFormCard() {
const [formValues, setFormValues] = useState({
login_identifier: "",
requester_name: "",
requester_email: "",
subject: "",
message: "",
});
Expand All @@ -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.";
}
Expand All @@ -56,13 +62,15 @@ 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(),
});
setSuccessMessage(response.message);
setFormValues({
login_identifier: "",
requester_name: "",
requester_email: "",
subject: "",
message: "",
});
Expand Down Expand Up @@ -121,6 +129,19 @@ export function LoginHelpFormCard() {
setFieldErrors((current) => ({ ...current, requester_name: undefined }));
}}
/>
<AuthInput
label="Reply email"
type="email"
placeholder="Enter the email address for admin replies"
name="requester_email"
autoComplete="email"
error={fieldErrors.requester_email}
value={formValues.requester_email}
onChange={(event) => {
setFormValues((current) => ({ ...current, requester_email: event.target.value }));
setFieldErrors((current) => ({ ...current, requester_email: undefined }));
}}
/>
</div>

<div className="mt-4 space-y-4">
Expand Down
Loading
Loading