bottleneckcrm/backend/cases/tasks.py

751 lines
29 KiB
Python

import hashlib
import logging
import re
from datetime import timedelta
from email.utils import make_msgid, parseaddr
from celery import shared_task
from django.conf import settings
from django.contrib.contenttypes.models import ContentType
from django.core.mail import EmailMessage
from django.core.mail.utils import DNS_NAME
from django.core.signing import TimestampSigner
from django.db.models import Q
from django.template.loader import render_to_string
from django.utils import timezone
from cases.inbound.threading import _SUBJECT_FALLBACK_RE as _SUBJECT_TAG_RE
from cases.inbound.threading import short_case_id
from cases.models import Case, CsatSurvey, EscalationPolicy, InboundMailbox, TimeEntry
from cases.models import EmailMessage as EmailMessageRecord
from cases.notifications import case_link
from cases.workflow import TERMINAL_STATUSES
from common.links import frontend_url
from common.models import Activity, Comment, Org, Profile
from common.modules import module_is_available, module_on_since
from common.tasks import clear_rls_context, set_rls_context
logger = logging.getLogger(__name__)
# Surveys live for 30 days from send before the link 410s.
CSAT_TOKEN_TTL_DAYS = 30
# Wait this long after a case closes before sending the survey, to avoid
# spamming customers when an agent flips status to Closed and then
# immediately reopens (Tier 1 reopen).
CSAT_SEND_DELAY_MINUTES = 30
# Salt scoping the TimestampSigner so a leak doesn't help forge tokens
# elsewhere in the codebase.
CSAT_SIGNER_SALT = "cases.csat_survey"
# The rating scale, in one place because two things read it: the survey email
# renders a star per value, and csat_views.CsatPublicView validates the POST
# against the same bounds. A scale that disagreed with the validator would put
# a star in the email that the API rejects on arrival.
CSAT_RATING_MIN = 1
CSAT_RATING_MAX = 5
# The ends of the scale, labelled. Mirrors SCALE_ENDS in the survey page so the
# email and the page it links to describe the same 1 and the same 5.
CSAT_SCALE_LOW_LABEL = "Not good"
CSAT_SCALE_HIGH_LABEL = "Great"
# Cap a single case at 3 escalations total. Past that, a human needs to step in.
ESCALATION_COUNT_CAP = 3
# Minimum gap between escalation attempts on the same case (prevents storming).
ESCALATION_COOLDOWN_MINUTES = 60
@shared_task
def send_email_to_assigned_user(recipients, case_id, org_id):
"""Send Mail To Users When they are assigned to a case"""
set_rls_context(org_id)
case = Case.objects.get(id=case_id, org_id=org_id)
created_by = case.created_by
for profile_id in recipients:
recipients_list = []
profile = Profile.objects.filter(
id=profile_id, org_id=org_id, is_active=True
).first()
if profile:
recipients_list.append(profile.user.email)
context = {}
context["url"] = frontend_url(case_link(case.id))
context["user"] = profile.user
context["case"] = case
context["created_by"] = created_by
subject = "Assigned to case."
html_content = render_to_string(
"assigned_to/cases_assigned.html", context=context
)
msg = EmailMessage(subject, html_content, to=recipients_list)
msg.content_subtype = "html"
msg.send()
def _dispatch_breach(case, action, target_profile, team, org_id):
"""Apply one breach action (notify / reassign / notify_and_reassign).
Returns the list of profile_ids that received an email so callers can record
them in the Activity metadata. Reassignment replaces the assignee set.
"""
notified_ids = []
if action in ("reassign", "notify_and_reassign") and target_profile is not None:
case.assigned_to.set([target_profile])
if action in ("notify", "notify_and_reassign"):
recipients = []
if target_profile is not None:
recipients.append(str(target_profile.id))
if team is not None:
recipients.extend(
str(pid)
for pid in team.users.filter(is_active=True).values_list(
"id", flat=True
)
)
# de-dupe while preserving order
seen = set()
recipients = [r for r in recipients if not (r in seen or seen.add(r))]
if recipients:
try:
send_email_to_assigned_user.delay(recipients, str(case.id), str(org_id))
except (
Exception
): # pragma: no cover. Broker glitches shouldn't lose the escalation
logger.exception(
"Failed to enqueue escalation email for case=%s", case.pk
)
notified_ids = recipients
return notified_ids
def _scan_org(org):
"""Run the breach scan for one org. Returns count of cases escalated."""
if not module_is_available(org, "tickets"):
return 0
# A deadline that passed while Tickets was off never escalates.
since = module_on_since(org, "tickets")
now = timezone.now()
cooldown_cutoff = now - timezone.timedelta(minutes=ESCALATION_COOLDOWN_MINUTES)
candidate_qs = (
Case.objects.filter(org=org, is_active=True)
.exclude(status__in=TERMINAL_STATUSES)
.filter(escalation_count__lt=ESCALATION_COUNT_CAP)
.filter(
Q(last_escalation_fired_at__isnull=True)
| Q(last_escalation_fired_at__lt=cooldown_cutoff)
)
)
fired = 0
policies = {
p.priority: p for p in EscalationPolicy.objects.filter(org=org, is_active=True)
}
if not policies:
return 0
for case in candidate_qs.iterator():
first_breach = case.is_sla_first_response_breached and (
since is None or case.first_response_sla_deadline >= since
)
resolution_breach = case.is_sla_resolution_breached and (
since is None or case.resolution_sla_deadline >= since
)
if not (first_breach or resolution_breach):
continue
policy = policies.get(case.priority)
if policy is None:
continue
breaches_metadata = []
if first_breach and policy.first_response_target_id is not None:
notified = _dispatch_breach(
case,
policy.first_response_action,
policy.first_response_target,
policy.notify_team,
org.id,
)
breaches_metadata.append(
{
"breach_type": "first_response",
"action": policy.first_response_action,
"target_profile_id": str(policy.first_response_target_id),
"notified_profile_ids": notified,
}
)
if resolution_breach and policy.resolution_target_id is not None:
notified = _dispatch_breach(
case,
policy.resolution_action,
policy.resolution_target,
policy.notify_team,
org.id,
)
breaches_metadata.append(
{
"breach_type": "resolution",
"action": policy.resolution_action,
"target_profile_id": str(policy.resolution_target_id),
"notified_profile_ids": notified,
}
)
if not breaches_metadata:
continue
case.escalation_count = (case.escalation_count or 0) + 1
case.last_escalation_fired_at = now
case.save(
update_fields=["escalation_count", "last_escalation_fired_at", "updated_at"]
)
Activity.objects.create(
user=None,
action="ESCALATED",
entity_type="Case",
entity_id=case.pk,
entity_name=str(case)[:255],
metadata={
"breaches": breaches_metadata,
"policy_id": str(policy.id),
"escalation_count": case.escalation_count,
},
org_id=org.id,
)
fired += 1
return fired
@shared_task
def scan_for_breached_cases():
"""Periodic scanner that fires escalation actions on SLA-breached cases.
Runs once every 5 minutes via Celery beat. For each org with at least one
active EscalationPolicy, checks non-terminal cases past either SLA deadline
that haven't escalated within the last hour and whose escalation_count is
below the cap, then dispatches the configured action(s) and records a single
Activity(action='ESCALATED').
Walks every org through the unscoped `organization` table and sets the RLS
context before any org-scoped query; `_scan_org` returns early for an org
with Tickets off or no active policy. The previous version found its orgs
by querying `escalation_policy` first, with no context set. That table is
org-scoped and a worker runs no middleware, so under an RLS-bound role the
lookup matched nothing and no case was ever escalated. The context is
cleared in a ``finally`` so the last org's id is not left on a pooled
connection.
"""
total = 0
try:
for org in Org.objects.all():
set_rls_context(org.id)
try:
total += _scan_org(org)
except Exception: # pragma: no cover
logger.exception("Escalation scan failed for org=%s", org.id)
finally:
clear_rls_context()
return total
# ---------------------------------------------------------------------------
# CSAT (Tier 2 csat)
def csat_signer() -> TimestampSigner:
"""Salted TimestampSigner shared by send + verify paths."""
return TimestampSigner(salt=CSAT_SIGNER_SALT)
def hash_csat_token(token: str) -> str:
"""SHA-256 hex digest. We never store raw tokens, only their hash."""
return hashlib.sha256(token.encode("utf-8")).hexdigest()
def _select_primary_contact(case: Case):
"""Pick the contact we'll mail. Prefer one with a non-blank email.
Cases with multiple contacts get a single survey to the first
email-bearing one (FK iteration order). Spec: each closed case is one
survey; do not bundle, do not split.
"""
return (
case.contacts.exclude(email__isnull=True)
.exclude(email="")
.order_by("created_at")
.first()
)
@shared_task
def send_csat_survey(case_id, org_id):
"""Send a CSAT survey for a freshly-closed case.
Skips when:
- Tickets is switched off for the org (the survey page answers 410).
- The org has flipped `csat_enabled` off.
- The case has no contact with an email (logged, not raised).
- The case has been reopened in the meantime (status no longer
Closed): the spec's reopen-protection clause.
- A survey row already exists for this case (don't double-send).
"""
set_rls_context(org_id)
case = Case.objects.filter(id=case_id, org_id=org_id).first()
if case is None:
logger.info("send_csat_survey: case=%s not found, skipping", case_id)
return None
if case.status != "Closed":
logger.info(
"send_csat_survey: case=%s status=%s, likely reopened, skipping",
case_id,
case.status,
)
return None
if not module_is_available(case.org, "tickets"):
logger.info("send_csat_survey: org=%s has Tickets off, skipping", org_id)
return None
if not case.org.csat_enabled:
logger.info("send_csat_survey: org=%s has csat disabled, skipping", org_id)
return None
if hasattr(case, "csat_survey"):
logger.info("send_csat_survey: case=%s already has a survey row", case_id)
return None
if _unanswered_web_form_ticket(case):
logger.info(
"send_csat_survey: case=%s is an unanswered web form ticket", case_id
)
return None
contact = _select_primary_contact(case)
if contact is None or not contact.email:
logger.info("send_csat_survey: case=%s has no contact email, skipping", case_id)
return None
now = timezone.now()
raw_token = csat_signer().sign(str(case.id))
survey = CsatSurvey.objects.create(
org_id=org_id,
case=case,
contact=contact,
token_hash=hash_csat_token(raw_token),
sent_at=now,
expires_at=now + timedelta(days=CSAT_TOKEN_TTL_DAYS),
)
# Register the unscoped token→org lookup so the anonymous survey view can
# resolve the org under RLS. hash_csat_token is sha256, matching the key
# portal_token_hash computes from the same URL token.
from common.portal_tokens import register_portal_token_hash
register_portal_token_hash(survey.token_hash, org_id, "csat", survey.id)
link = frontend_url(f"/csat/{raw_token}")
context = {
"case": case,
"contact": contact,
"org": case.org,
"link": link,
# Each value becomes a star linking to `{link}?rating=<value>`, which
# only pre-selects on the page. Nothing is recorded until that page
# POSTs, because a link is a GET and mail scanners follow GETs.
"rating_scale": range(CSAT_RATING_MIN, CSAT_RATING_MAX + 1),
"scale_low_label": CSAT_SCALE_LOW_LABEL,
"scale_high_label": CSAT_SCALE_HIGH_LABEL,
}
# Deliberately unguarded. This render used to sit under a bare
# `except Exception` that fell back to a plain link, with a comment saying
# the template existed in production. It never existed anywhere, so every
# survey ever sent took the fallback and nobody found out. A template that
# fails to render is now a task failure that Celery logs.
html = render_to_string("csat/survey_email.html", context=context)
msg = EmailMessage(
subject=f"How did we do?, {case.name}",
body=html,
to=[contact.email],
)
msg.content_subtype = "html"
try:
msg.send(fail_silently=False)
except Exception:
# The survey row is already written; a retry strategy can pick it
# up by token_hash. We don't tear down the row because the agent
# CAN re-send manually if needed.
logger.exception(
"send_csat_survey: email send failed for case=%s contact=%s",
case_id,
contact.id,
)
return str(survey.id)
# Threading for the customer-facing case emails below. A reply the customer
# sends back carries our Message-ID in In-Reply-To, which the inbound pipeline
# matches against `EmailMessage.message_id`; the subject tag catches the mail
# clients and relays that strip those headers.
#
# References keeps the thread's root plus this many of its latest ids, so a
# long ticket does not grow the header without limit.
THREAD_REFERENCES_TAIL = 10
# Printable ASCII with no whitespace and no angle brackets. Stored ids come
# from inbound mail, which the sender controls, so an id that does not fit is
# left out of our headers rather than trusted to be well formed.
_SAFE_MESSAGE_ID_RE = re.compile(r"^[!-;=?-~]{1,512}$")
def _safe_ids(ids):
"""Drop unusable ids and repeats, keeping the order."""
seen = set()
out = []
for value in ids:
if value and _SAFE_MESSAGE_ID_RE.match(value) and value not in seen:
seen.add(value)
out.append(value)
return out
def _thread_headers(case):
"""Return (in_reply_to, references) for the next email on `case`.
Ordered by our own clock (`created_at`) rather than `received_at`, which
for inbound mail is the sender's Date header.
"""
rows = EmailMessageRecord.objects.filter(org_id=case.org_id, case=case)
latest = list(
rows.order_by("-created_at").values_list("message_id", flat=True)[
:THREAD_REFERENCES_TAIL
]
)
root = case.external_thread_id or (
rows.order_by("created_at").values_list("message_id", flat=True).first()
)
references = _safe_ids([root, *reversed(latest)])
in_reply_to = _safe_ids([*latest[:1], case.external_thread_id])
return (in_reply_to[0] if in_reply_to else ""), references
def _thread_subject(case):
"""`Re: <name> [Case #xxxxxxxx]`, with CR/LF and any older tag removed.
Any `[Case #...]` already in the name (a case born from a reply subject
carries one) is stripped, so the tag appears once and it is this case's.
Collapsing whitespace is what keeps a user-typed newline in the name out
of the header block: Django would refuse the whole message otherwise.
"""
name = _SUBJECT_TAG_RE.sub(" ", case.name or "")
return " ".join(f"Re: {name} [Case #{short_case_id(case)}]".split())
def _reply_to_mailbox(case):
"""The inbound mailbox a customer's email reply should go to, or None.
The one the case's latest inbound email came through; else the org's only
mailbox. Only a mailbox that can take mail counts: active, and an SES one
with its topic pinned, since the webhook refuses everything else and a
reply sent there would be lost. Always this case's org.
"""
receiving = InboundMailbox.objects.filter(
org_id=case.org_id, is_active=True, provider="ses"
).exclude(topic_arn="")
last_mailbox_id = (
EmailMessageRecord.objects.filter(
org_id=case.org_id,
case=case,
direction="inbound",
mailbox__isnull=False,
)
.order_by("-created_at")
.values_list("mailbox_id", flat=True)
.first()
)
if last_mailbox_id:
mailbox = receiving.filter(id=last_mailbox_id).first()
if mailbox is not None:
return mailbox
only = list(receiving[:2])
return only[0] if len(only) == 1 else None
def _unanswered_web_form_ticket(case):
"""True for a ticket a public web form created that no agent has answered.
Anybody can post a ticket form with any address, and the service attaches
a contact for it, so until somebody on our side engages, the address is a
stranger's claim. Mailing it status changes and a CSAT survey would let
that stranger make the org's sender write to anyone, under a subject the
stranger chose. An agent's first public reply is the deliberate decision
to engage, and it is what stamps `first_response_at`
(`cases.signals._maybe_stamp_first_response`), so an answered ticket costs
no query here. The reply itself is still emailed, since the agent chose to
send it.
"""
if case.first_response_at is not None:
return False
return case.webform_submissions.filter(org_id=case.org_id).exists()
def _ses_message_id(ses_id):
"""The Message-ID header SES puts on a message it accepted as `ses_id`.
SES replaces any Message-ID we supply (AWS "Amazon SES header fields"), and
django-ses reports only the bare id, in `extra_headers["message_id"]`. The
header SES writes is `<id@email.amazonses.com>` in us-east-1 and
`<id@<region>.amazonses.com>` in every other region, and that full form is
what the customer's reply quotes in In-Reply-To. django-ses sends through
`AWS_SES_REGION_NAME`, defaulting to us-east-1.
"""
region = getattr(settings, "AWS_SES_REGION_NAME", "") or "us-east-1"
host = "email" if region == "us-east-1" else region
return f"{ses_id}@{host}.amazonses.com"
def _claim_thread_root(case, msgid_domain):
"""The case's thread root id, minted from our own domain if it has none.
A case opened in the portal, by an agent or from a web form has no root,
so its first email's References was empty. When SES then rewrote the
Message-ID, a reply matched nothing by header. The root is our own id, set
once and carried in References of every email on the case, and
`find_existing_case` matches it on `external_thread_id`, so a reply threads
by header whatever the provider did to the Message-ID. A concurrent task
that set it first wins, and its value is used.
"""
root = make_msgid(domain=msgid_domain).strip("<>")
Case.objects.filter(
Q(external_thread_id__isnull=True) | Q(external_thread_id=""),
pk=case.pk,
org_id=case.org_id,
).update(external_thread_id=root)
return (
Case.objects.filter(pk=case.pk, org_id=case.org_id)
.values_list("external_thread_id", flat=True)
.first()
)
def _author_name(comment, org_name):
if comment.commented_by_id:
return comment.commented_by.user.name or f"{org_name} support"
contact = comment.commented_by_contact
if contact is not None:
return f"{contact.first_name} {contact.last_name}".strip() or "A contact"
return f"{org_name} support"
@shared_task
def notify_portal_contacts(
case_id, org_id, kind, actor_contact_id=None, comment_id=None
):
"""Email the customer that something happened on their case.
`kind` is "reply" or "status". Unlike `_select_primary_contact`, which
deliberately picks a single recipient for CSAT, this mails every contact on
the case that has an address, because any of them may be the one waiting.
`actor_contact_id` is excluded, so nobody is emailed about their own reply.
A reply email carries the reply itself (`comment_id`), escaped by the
template. The comment is re-read here and must still be public, so a reply
turned into an internal note before this ran is never sent.
A status email is not sent on a web form ticket no agent has answered yet
(`_unanswered_web_form_ticket`); a reply email always is.
Every email is threaded: a fresh Message-ID, In-Reply-To and References
from the case's known messages plus its thread root (`_claim_thread_root`),
and a `[Case #xxxxxxxx]` subject tag. Each one sent is recorded as an
outbound `EmailMessage` under the Message-ID the customer actually
receives (`_ses_message_id` when SES replaced ours), which is what lets
the customer's email reply land back on this case.
The portal link requires signing in and carries no token, so forwarding the
email does not forward access. That is the difference between this and the
invoice and estimate mails, where the token in the URL is the credential.
"""
set_rls_context(org_id)
case = Case.objects.filter(id=case_id, org_id=org_id).select_related("org").first()
if case is None:
logger.info("notify_portal_contacts: case=%s not found, skipping", case_id)
return None
# Queued while Tickets was on; once it is off, the portal page the email
# links to answers not found.
if not module_is_available(case.org, "tickets"):
logger.info("notify_portal_contacts: org=%s has Tickets off, skipping", org_id)
return 0
comment = None
if kind == "reply":
comment = (
Comment.objects.filter(
id=comment_id,
org_id=org_id,
content_type=ContentType.objects.get_for_model(Case),
object_id=case.id,
is_internal=False,
)
.select_related("commented_by__user", "commented_by_contact")
.first()
)
if comment is None:
logger.info(
"notify_portal_contacts: no public comment=%s on case=%s, skipping",
comment_id,
case_id,
)
return 0
elif _unanswered_web_form_ticket(case):
logger.info(
"notify_portal_contacts: case=%s is an unanswered web form ticket, "
"skipping status email",
case_id,
)
return 0
recipients = (
case.contacts.filter(is_active=True)
.exclude(email__isnull=True)
.exclude(email="")
)
if actor_contact_id:
recipients = recipients.exclude(id=actor_contact_id)
recipients = list(recipients)
if not recipients:
return 0
# The org rides in the query string because the recipient may have no
# portal cookie on the device they read this on: a phone, a colleague's
# machine, a browser they cleared. Without it the sign-in fallback has no
# idea which tenant's portal to send them to and drops them on the internal
# staff login, which a customer cannot use. It is an id that already
# appears in the URL of every portal page, not a credential, and it grants
# nothing on its own.
link = frontend_url(f"/portal/cases/{case.id}?org={case.org_id}")
org_name = case.org.name or ""
subject = _thread_subject(case)
from_address = parseaddr(settings.DEFAULT_FROM_EMAIL)[1]
msgid_domain = from_address.rpartition("@")[2] or str(DNS_NAME)
in_reply_to, references = _thread_headers(case)
if not case.external_thread_id:
references = _safe_ids([_claim_thread_root(case, msgid_domain), *references])
mailbox = _reply_to_mailbox(case)
if comment is not None:
author_name = _author_name(comment, org_name)
body_text = comment.comment
else:
author_name = ""
body_text = f"Your request is now {case.status}."
headers = {}
if in_reply_to:
headers["In-Reply-To"] = f"<{in_reply_to}>"
if references:
headers["References"] = " ".join(f"<{ref}>" for ref in references)
sent = 0
for contact in recipients:
html = render_to_string(
"portal/case_update_email.html",
{
"contact_name": contact.first_name or "",
"case_name": case.name,
"case_status": case.status,
"kind": kind,
"author_name": author_name,
"reply_text": body_text,
"reply_by_email": mailbox is not None,
"link": link,
"org_name": org_name,
},
)
message_id = make_msgid(domain=msgid_domain)
msg = EmailMessage(
subject,
html,
to=[contact.email],
reply_to=[mailbox.address] if mailbox is not None else None,
headers={**headers, "Message-ID": message_id},
)
msg.content_subtype = "html"
try:
msg.send(fail_silently=False)
except Exception as exc:
# One bad address must not stop the rest of the thread being told.
# Only the exception type is logged: an SMTP or SES error message
# usually quotes the recipient's address.
logger.error(
"notify_portal_contacts: send failed for case=%s contact=%s (%s)",
case_id,
contact.id,
type(exc).__name__,
)
continue
sent += 1
ses_id = msg.extra_headers.get("message_id")
EmailMessageRecord.objects.create(
org_id=case.org_id,
case=case,
mailbox=mailbox,
direction="outbound",
message_id=(_ses_message_id(ses_id) if ses_id else message_id.strip("<>")),
in_reply_to=in_reply_to,
references=" ".join(references),
from_address=from_address,
to_addresses=contact.email,
subject=subject[:512],
body_text=body_text,
body_html=html,
received_at=timezone.now(),
)
return sent
# ---------------------------------------------------------------------------
# Tier 3 time-tracking: auto-stop forgotten timers.
# A running timer this old gets killed by the Celery beat. Hand-tuned: 12h
# covers an overnight forgotten timer without clobbering a mid-day session
# someone left running through lunch.
TIME_ENTRY_AUTO_STOP_HOURS = 12
@shared_task
def auto_stop_stale_timers(threshold_hours=TIME_ENTRY_AUTO_STOP_HOURS):
"""Stop running TimeEntry rows whose started_at is older than
``threshold_hours``. Sets ``auto_stopped=True`` and recomputes
``duration_minutes`` via the model save() path. Skips an org with Timesheet
off, and leaves running a timer that went stale while it was off.
Returns the number of timers stopped. Walks every org through the
unscoped `organization` table and sets the RLS context before touching
`time_entry`. The previous version collected its org ids from `time_entry`
itself before any context was set, which under an RLS-bound role matched
nothing, so no timer was ever stopped.
"""
now = timezone.now()
cutoff = now - timedelta(hours=threshold_hours)
stopped = 0
try:
for org in Org.objects.all():
if not module_is_available(org, "timesheet"):
continue
set_rls_context(org.id)
try:
stale = TimeEntry.objects.filter(
org_id=org.id, ended_at__isnull=True, started_at__lt=cutoff
)
since = module_on_since(org, "timesheet")
if since is not None:
stale = stale.filter(
started_at__gte=since - timedelta(hours=threshold_hours)
)
for entry in list(stale):
entry.ended_at = now
entry.auto_stopped = True
entry.save()
stopped += 1
except Exception: # pragma: no cover
logger.exception("auto_stop_stale_timers failed for org=%s", org.id)
finally:
clear_rls_context()
return stopped