Skip to content

Commit 4cc544c

Browse files
feat: send email manually on candidate withdrawal
1 parent ee10f18 commit 4cc544c

8 files changed

Lines changed: 316 additions & 48 deletions

File tree

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
<!DOCTYPE html>
2+
<!-- djlint:off H005 H016 H030 H031 -->
3+
<html>
4+
<head>
5+
<meta charset="utf-8">
6+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
7+
</head>
8+
<body style="margin: 0; padding: 0; background-color: #f4f4f4; font-family: Ubuntu, 'Helvetica Neue', Helvetica, Arial, sans-serif; font-weight: 300; color: #222;">
9+
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background-color: #f4f4f4;">
10+
<tr>
11+
<td align="center" style="padding: 40px 20px;">
12+
<table role="presentation" width="600" cellpadding="0" cellspacing="0" style="background-color: #ffffff; border-radius: 4px;">
13+
<tr>
14+
<td style="padding: 40px;">
15+
<p style="margin: 0 0 16px; font-size: 16px; line-height: 1.6;">Dear {{ candidate_name }},</p>
16+
<p style="margin: 0 0 16px; font-size: 16px; line-height: 1.6;">This email is to acknowledge your withdrawal from consideration for this role. As we are closing your application, your Candidate Dashboard for this application will be deactivated shortly. Thank you for the time you have spent on this application and best wishes for your next career step.</p>
17+
<p style="margin: 0 0 24px; font-size: 16px; line-height: 1.6;">Regards,<br>{{ hiring_lead_name }}</p>
18+
<p style="margin: 0; font-size: 14px; line-height: 1.6; color: #666;">For further information on data collection, please refer to our <a href="https://ubuntu.com/legal/data-privacy/recruitment" style="color: #2b6cb0; text-decoration: none;">recruitment privacy notice</a> and <a href="https://ubuntu.com/legal/data-privacy" style="color: #2b6cb0; text-decoration: none;">privacy policy</a>.</p>
19+
</td>
20+
</tr>
21+
</table>
22+
</td>
23+
</tr>
24+
</table>
25+
</body>
26+
</html>
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
{% macro debug_email_notification(recipient, message, sender=None) %}
2+
<div class="u-fixed-width">
3+
<div class="p-notification--caution">
4+
<div class="p-notification__content">
5+
<h5 class="p-notification__title">[DEBUG] This email wasn't sent because you're in debug mode</h5>
6+
<p>The following email would have been sent to {{ recipient }}{% if sender %} from {{ sender }}{% endif %}:</p>
7+
<p><blockquote>{{ message | safe }}</blockquote></p>
8+
</div>
9+
</div>
10+
</div>
11+
{% endmacro %}

templates/careers/application/_withdrawal_notification-email.html

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
<div>
22
<p>Dear {{ hiring_lead_name }},</p>
33
<p>This is an automated email to inform you that {{ applicant_name }} has withdrawn their application for the {{ position }} position.</p>
4+
{% if candidate_email_failed %}
5+
<p>The candidate withdrawal confirmation email could not be sent. Please contact the candidate directly.</p>
6+
{% endif %}
47
<p>The candidate was in {{ current_stage["name"] }} stage.</p>
58
<p><a href="{{ application_url }}">Click here to access the candidate's application.</a></p>
69
<p>Regards,<br>Talent Science Engineering</p>

templates/careers/application/withdrawal.html

Lines changed: 4 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
{% extends 'base_index.html' %}
2+
{% from 'careers/application/_debug-email-notification.html' import debug_email_notification %}
23

34
{% block content %}
45

@@ -9,29 +10,14 @@
910
end up skipping sending the email in production in a silent way that nobody notices.
1011
-->
1112

12-
<div class="u-fixed-width">
13-
<div class="p-notification--caution">
14-
<div class="p-notification__content">
15-
<h5 class="p-notification__title">[DEBUG] This email wasn't sent because you're in debug mode</h5>
16-
<p>The following email would have been sent to {{ hiring_lead_email }}:</p>
17-
<p><blockquote>{{ email_message | safe }}</blockquote></p>
18-
</div>
19-
</div>
20-
</div>
13+
{{ debug_email_notification(candidate_email, candidate_email_message, candidate_email_from) }}
14+
{{ debug_email_notification(hiring_lead_email, email_message) }}
2115

2216
<!--
2317
Similarly for interview canceled/feedback not needed emails
2418
-->
2519
{% for email in all_sent_emails %}
26-
<div class="u-fixed-width">
27-
<div class="p-notification--caution">
28-
<div class="p-notification__content">
29-
<h5 class="p-notification__title">[DEBUG] This email wasn't sent because you're in debug mode</h5>
30-
<p>The following email would have been sent to {{ email["interviewer"] }}:</p>
31-
<p><blockquote>{{ email["message"] | safe }}</blockquote></p>
32-
</div>
33-
</div>
34-
</div>
20+
{{ debug_email_notification(email["interviewer"], email["message"]) }}
3521
{% endfor %}
3622
{% endif %}
3723
</section>

tests/test_application.py

Lines changed: 143 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -319,16 +319,21 @@ def setUp(self):
319319
# fake constants
320320
self.fake_application = {
321321
"id": "123",
322+
"status": "active",
322323
"role_name": "Fake Job",
323324
"candidate": {
324325
"id": "444",
325326
"first_name": "John",
326327
"last_name": "Doe",
328+
"email_addresses": [
329+
{"value": "candidate@example.com", "type": "personal"}
330+
],
327331
},
328332
"hiring_lead": {
329333
"id": "777",
330334
"name": "Hiring Lead",
331335
"emails": ["hiring_lead@example.com"],
336+
"primary_email": "hiring_lead@example.com",
332337
},
333338
"current_stage": {"name": "Fake Stage"},
334339
"jobs": [{"id": 456, "name": "Fake Job"}],
@@ -424,6 +429,18 @@ def setUp(self):
424429
# set debug to true so we can test and ensure _send_mail is not called
425430
flask.current_app.debug = True
426431

432+
def rendered_call(self, template_suffix):
433+
"""Return the single render_template call for a given template."""
434+
calls = [
435+
call
436+
for call in self.mock_render_template.call_args_list
437+
if call.args[0].endswith(template_suffix)
438+
]
439+
self.assertEqual(
440+
len(calls), 1, f"expected one render of {template_suffix}"
441+
)
442+
return calls[0]
443+
427444
def test_candidate_withdrawal_process(self):
428445
call_order = []
429446
mock_harvest = MagicMock(spec=HarvestV3)
@@ -475,14 +492,11 @@ def delete_interview(*args, **kwargs):
475492

476493
# ensure datetime conversion worked
477494
expected_datetime = "February 29, 2024 at 03:00PM"
478-
_, kwargs = self.mock_render_template.call_args_list[0]
495+
kwargs = self.rendered_call("interview-canceled-email.html").kwargs
479496
self.assertIn("interview_date", kwargs)
480497
self.assertEqual(kwargs["interview_date"], expected_datetime)
481-
self.assertEqual(self.mock_render_template.call_count, 4)
482-
feedback_template = self.mock_render_template.call_args_list[1].args[0]
483-
self.assertTrue(
484-
feedback_template.endswith("feedback-not-needed-email.html")
485-
)
498+
self.assertEqual(self.mock_render_template.call_count, 5)
499+
self.rendered_call("feedback-not-needed-email.html")
486500

487501
# ensure that harvest rejection fn is called with correct arguments
488502
mock_harvest.reject_application.assert_called_once_with(
@@ -528,6 +542,106 @@ def test_missing_withdrawal_reason_returns_bad_request(self):
528542

529543
mock_harvest.reject_application.assert_not_called()
530544

545+
def test_candidate_email_uses_hiring_lead_as_sender(self):
546+
flask.current_app.debug = False
547+
mock_harvest = MagicMock(spec=HarvestV3)
548+
mock_harvest.reject_application.return_value = MagicMock(
549+
status_code=204
550+
)
551+
self.mock_render_template.side_effect = lambda template, **kwargs: (
552+
template
553+
)
554+
555+
application_withdrawal(mock_harvest, "fake_token")
556+
557+
candidate_email_call = self.mock_send_mail.call_args_list[0]
558+
self.assertEqual(
559+
candidate_email_call.args[0], ["candidate@example.com"]
560+
)
561+
self.assertEqual(
562+
candidate_email_call.args[1],
563+
"Withdrawal of application for Fake Job, Canonical",
564+
)
565+
self.assertEqual(
566+
candidate_email_call.kwargs["from_email"],
567+
"hiring_lead@example.com",
568+
)
569+
570+
def test_candidate_email_failure_is_reported_to_hiring_lead(self):
571+
flask.current_app.debug = False
572+
mock_harvest = MagicMock(spec=HarvestV3)
573+
mock_harvest.reject_application.return_value = MagicMock(
574+
status_code=204
575+
)
576+
self.mock_send_mail.side_effect = [RuntimeError("SMTP failed"), None]
577+
578+
application_withdrawal(mock_harvest, "fake_token")
579+
580+
notification_call = self.rendered_call(
581+
"_withdrawal_notification-email.html"
582+
)
583+
self.assertTrue(notification_call.kwargs["candidate_email_failed"])
584+
self.assertEqual(self.mock_send_mail.call_count, 2)
585+
586+
def test_candidate_email_precedes_interview_cleanup(self):
587+
flask.current_app.debug = False
588+
mock_harvest = MagicMock(spec=HarvestV3)
589+
mock_harvest.reject_application.return_value = MagicMock(
590+
status_code=204
591+
)
592+
call_order = []
593+
594+
def record_email(_to_email, subject, *args, **kwargs):
595+
if subject.startswith("Withdrawal of application"):
596+
call_order.append("candidate")
597+
else:
598+
call_order.append("hiring_lead")
599+
600+
def record_interview_cleanup(*args):
601+
call_order.append("interviews")
602+
return []
603+
604+
self.mock_send_mail.side_effect = record_email
605+
with patch(
606+
"webapp.application.try_reject_interviews",
607+
side_effect=record_interview_cleanup,
608+
):
609+
application_withdrawal(mock_harvest, "fake_token")
610+
611+
self.assertEqual(
612+
call_order,
613+
["candidate", "interviews", "hiring_lead"],
614+
)
615+
616+
@patch("webapp.application.logger")
617+
def test_hiring_lead_email_failure_does_not_mask_withdrawal(
618+
self, mock_logger
619+
):
620+
flask.current_app.debug = False
621+
mock_harvest = MagicMock(spec=HarvestV3)
622+
mock_harvest.reject_application.return_value = MagicMock(
623+
status_code=204
624+
)
625+
self.mock_send_mail.side_effect = [None, RuntimeError("SMTP failed")]
626+
627+
application_withdrawal(mock_harvest, "fake_token")
628+
629+
mock_logger.exception.assert_called_once_with(
630+
"failed to send withdrawal notification to hiring lead for "
631+
"application_id=%s",
632+
self.fake_application["id"],
633+
)
634+
635+
def test_already_rejected_application_has_no_side_effects(self):
636+
self.fake_application["status"] = "rejected"
637+
mock_harvest = MagicMock(spec=HarvestV3)
638+
639+
application_withdrawal(mock_harvest, "fake_token")
640+
641+
mock_harvest.reject_application.assert_not_called()
642+
self.mock_send_mail.assert_not_called()
643+
self.mock_cal.return_value.delete_interview_event.assert_not_called()
644+
531645
def tearDown(self):
532646
# stop all of the patches from setUp
533647
patch.stopall()
@@ -828,6 +942,29 @@ def test_send_mail_without_authentication(self, mock_smtp):
828942
smtp_instance.send_message.assert_called_once()
829943
smtp_instance.quit.assert_called_once()
830944

945+
@patch("webapp.application.SMTP")
946+
def test_send_mail_supports_custom_sender(self, mock_smtp):
947+
smtp_instance = mock_smtp.return_value
948+
with patch.dict(
949+
os.environ,
950+
{
951+
"SMTP_SERVER": "smtp.example.com",
952+
"SMTP_USER": "",
953+
"SMTP_PASS": "",
954+
"SMTP_SENDER_ADDRESS": "fallback@example.com",
955+
},
956+
clear=True,
957+
):
958+
_send_mail(
959+
to_email=["recipient@example.com"],
960+
subject="Subject",
961+
message="<p>HTML body</p>",
962+
from_email="lead@example.com",
963+
)
964+
965+
message = smtp_instance.send_message.call_args.args[0]
966+
self.assertEqual(message["From"], "lead@example.com")
967+
831968

832969
class TestConfirmationToken(unittest.TestCase):
833970
@patch("webapp.application._get_cipher")

tests/test_greenhouse.py

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -538,7 +538,6 @@ def test_reject_application(self):
538538
json={
539539
"rejection_reason_id": 3,
540540
"notes": "note",
541-
"rejection_email": {"email_template_id": 348528},
542541
},
543542
headers={
544543
"Content-Type": "application/json",
@@ -547,6 +546,40 @@ def test_reject_application(self):
547546
timeout=30,
548547
)
549548

549+
def test_reject_application_recovers_when_application_is_rejected(self):
550+
rejected_response = self._mock_response(None, status_code=422)
551+
error = requests.exceptions.HTTPError(response=rejected_response)
552+
rejected_response.raise_for_status.side_effect = error
553+
application_response = self._mock_response(
554+
[{"id": 1, "status": "rejected"}]
555+
)
556+
self.session.request.side_effect = [
557+
rejected_response,
558+
application_response,
559+
]
560+
561+
response = self.harvest.reject_application("1", "3", "note")
562+
563+
self.assertEqual(response.status_code, 204)
564+
self.assertEqual(self.session.request.call_count, 2)
565+
566+
def test_reject_application_reraises_when_application_is_active(self):
567+
rejected_response = self._mock_response(None, status_code=422)
568+
error = requests.exceptions.HTTPError(response=rejected_response)
569+
rejected_response.raise_for_status.side_effect = error
570+
application_response = self._mock_response(
571+
[{"id": 1, "status": "in_process"}]
572+
)
573+
self.session.request.side_effect = [
574+
rejected_response,
575+
application_response,
576+
]
577+
578+
with self.assertRaises(requests.exceptions.HTTPError) as context:
579+
self.harvest.reject_application("1", "3", "note")
580+
581+
self.assertIs(context.exception, error)
582+
550583
def test_reject_application_requires_reason(self):
551584
with self.assertRaisesRegex(
552585
ValueError, "rejection_reason_id is required"

0 commit comments

Comments
 (0)