From 9e8697c7eb7e232a2d71759f52e86ff4c2f084c8 Mon Sep 17 00:00:00 2001 From: Michael Wu Date: Sat, 6 Jun 2026 11:06:17 +0800 Subject: [PATCH] Add onboarding email command --- .env.example | 8 + ENVIRONMENT.md | 12 + .../discord_bot/cogs/onboarding_email.py | 1039 +++++++++++++++++ .../src/five08/discord_bot/config.py | 56 + .../shared/src/five08/onboarding_email.py | 311 +++++ tests/unit/test_bot.py | 23 + tests/unit/test_onboarding_email.py | 72 ++ tests/unit/test_onboarding_email_cog.py | 718 ++++++++++++ 8 files changed, 2239 insertions(+) create mode 100644 apps/discord_bot/src/five08/discord_bot/cogs/onboarding_email.py create mode 100644 packages/shared/src/five08/onboarding_email.py create mode 100644 tests/unit/test_onboarding_email.py create mode 100644 tests/unit/test_onboarding_email_cog.py diff --git a/.env.example b/.env.example index 72190a3a..c7e19e5a 100644 --- a/.env.example +++ b/.env.example @@ -221,6 +221,14 @@ EMAIL_USERNAME=your_email@example.com EMAIL_PASSWORD=your_app_password IMAP_SERVER=imap.migadu.com IMAP_TIMEOUT_SECONDS=10.0 +# Onboarding email sending for /onboarding-email send=true +ONBOARDING_EMAIL_SMTP_SERVER=smtp.migadu.com +ONBOARDING_EMAIL_SMTP_PORT=465 +ONBOARDING_EMAIL_SMTP_USE_SSL=true +ONBOARDING_EMAIL_SMTP_STARTTLS=false +ONBOARDING_EMAIL_SMTP_USERNAME=onboarding@508.dev +ONBOARDING_EMAIL_SMTP_PASSWORD=your_app_password +ONBOARDING_EMAIL_SENDER_EMAIL=onboarding@508.dev # Intake form resume URL fetch guardrails INTAKE_RESUME_FETCH_TIMEOUT_SECONDS=20.0 INTAKE_RESUME_MAX_REDIRECTS=3 diff --git a/ENVIRONMENT.md b/ENVIRONMENT.md index 03f08096..c657f634 100644 --- a/ENVIRONMENT.md +++ b/ENVIRONMENT.md @@ -134,6 +134,18 @@ current precedence rules. - `Required when EMAIL_RESUME_INTAKE_ENABLED=true`: `EMAIL_USERNAME`, `EMAIL_PASSWORD`, `IMAP_SERVER` - Note: resume intake writes LinkedIn URLs to `cLinkedIn`, leaves the intake-completed field unset, and matches resume filenames using `resume,cv,curriculum`. +## Onboarding Email Sending + +- `Optional`: `ONBOARDING_EMAIL_SMTP_SERVER` (falls back to `SMTP_SERVER`; for Migadu use `smtp.migadu.com`) +- `Optional`: `ONBOARDING_EMAIL_SMTP_PORT` (falls back to `SMTP_PORT`; default: `465`) +- `Optional`: `ONBOARDING_EMAIL_SMTP_USE_SSL` (falls back to `SMTP_USE_SSL`; default: `true`) +- `Optional`: `ONBOARDING_EMAIL_SMTP_STARTTLS` (falls back to `SMTP_STARTTLS`; default: `false`; use only when SSL is disabled) +- `Optional`: `ONBOARDING_EMAIL_SMTP_USERNAME` (falls back to `EMAIL_USERNAME`) +- `Optional`: `ONBOARDING_EMAIL_SMTP_PASSWORD` (falls back to `EMAIL_PASSWORD`) +- `Optional`: `ONBOARDING_EMAIL_SENDER_EMAIL` (default: `onboarding@508.dev`) +- `Optional`: `ONBOARDING_EMAIL_SMTP_TIMEOUT_SECONDS` (default: `20.0`) +- Note: `/onboarding-email` is limited to Steering Committee+ or the candidate's designated CRM onboarder. `send=true` sends from the configured sender address with the command sender's name as the display name, and sets `Reply-To` to the command user's CRM-linked email or the explicit `reply_to_email` option. + ## Discord Bot Core - `Optional`: `BACKEND_API_BASE_URL` (default: `http://127.0.0.1:8090`; `./scripts/dev.sh` overrides it to the worktree web/API port, Compose injects `http://web:8090`) diff --git a/apps/discord_bot/src/five08/discord_bot/cogs/onboarding_email.py b/apps/discord_bot/src/five08/discord_bot/cogs/onboarding_email.py new file mode 100644 index 00000000..f54a8cfc --- /dev/null +++ b/apps/discord_bot/src/five08/discord_bot/cogs/onboarding_email.py @@ -0,0 +1,1039 @@ +"""Discord command for generating and sending onboarding email drafts.""" + +from __future__ import annotations + +import asyncio +from dataclasses import dataclass +from email.message import EmailMessage +from email.utils import formataddr, parseaddr +import logging +import re +import smtplib +import ssl +from typing import Any + +import discord +from discord import app_commands +from discord.ext import commands + +from five08.clients.espo import EspoAPIError, EspoClient +from five08.discord_bot.config import settings +from five08.discord_bot.utils.audit import DiscordAuditCogMixin +from five08.discord_bot.utils.role_decorators import check_user_roles_with_hierarchy +from five08.onboarding_email import OnboardingEmailRequest, build_onboarding_email + +logger = logging.getLogger(__name__) +NO_MENTIONS = discord.AllowedMentions.none() +EMAIL_RE = re.compile(r"^[^\s@]+@[^\s@]+\.[^\s@]+$") +ONBOARDER_FIELD = "cOnboarder" +ONBOARDING_STATUS_FIELD = "cOnboardingState" +ONBOARDING_EMAIL_CONTACT_SELECT_FIELDS = ( + f"id,name,emailAddress,c508Email,{ONBOARDER_FIELD},{ONBOARDING_STATUS_FIELD}" +) +ALREADY_ONBOARDED_STATES = frozenset({"onboarded"}) + + +@dataclass(frozen=True, slots=True) +class OnboardingEmailCommandState: + """Normalized slash-command inputs carried into selection callbacks.""" + + candidate_name: str + has_contributed: bool + recipient_email: str | None + discord_joined: str + agreement_signed: str + sender_display_name: str + signature_name: str + reply_to_email: str | None + send: bool + + +def _truncate_component_text(value: str, *, limit: int) -> str: + if len(value) <= limit: + return value + if limit <= 3: + return value[:limit] + return value[: limit - 3].rstrip() + "..." + + +class OnboardingEmailContactSelect( + discord.ui.Select["OnboardingEmailContactSelectView"] +): + """Select menu for resolving multiple CRM candidate matches.""" + + def __init__(self, contacts: list[dict[str, Any]]) -> None: + self._contact_lookup = { + str(contact["id"]): contact + for contact in contacts + if str(contact.get("id") or "").strip() + } + options: list[discord.SelectOption] = [] + for contact in contacts[:25]: + contact_id = str(contact.get("id") or "").strip() + if not contact_id: + continue + name = str(contact.get("name") or "Unknown") + status = OnboardingEmailCog._normalize_onboarding_status( + contact.get(ONBOARDING_STATUS_FIELD) + ) + status_label = status or "unknown" + email = OnboardingEmailCog._preferred_contact_email(contact) or "no email" + onboarder = str(contact.get(ONBOARDER_FIELD) or "unassigned").strip() + description = _truncate_component_text( + f"{email} | status: {status_label} | onboarder: {onboarder}", + limit=100, + ) + options.append( + discord.SelectOption( + label=_truncate_component_text(name, limit=100), + value=contact_id, + description=description, + ) + ) + + super().__init__( + placeholder="Select the candidate to draft/send onboarding email...", + min_values=1, + max_values=1, + options=options, + custom_id="onboarding_email_contact_select", + ) + + async def callback(self, interaction: discord.Interaction) -> None: + if not isinstance(self.view, OnboardingEmailContactSelectView): + await interaction.response.send_message( + "❌ Candidate selection is no longer available.", + allowed_mentions=NO_MENTIONS, + ephemeral=True, + ) + return + + contact = self._contact_lookup.get(self.values[0]) + if contact is None: + await interaction.response.send_message( + "❌ Selected candidate could not be resolved.", + allowed_mentions=NO_MENTIONS, + ephemeral=True, + ) + return + + await interaction.response.defer(ephemeral=True) + await self.view.cog._run_onboarding_email_flow( + interaction, + state=self.view.state, + selected_contact=contact, + ) + + for item in self.view.children: + if isinstance(item, (discord.ui.Button, discord.ui.Select)): + item.disabled = True + + if interaction.message: + try: + await interaction.message.edit(view=self.view) + except discord.NotFound: + pass + except discord.HTTPException as exc: + logger.warning( + "Failed to disable onboarding email candidate selector: %s", + exc, + ) + + +class OnboardingEmailContactSelectView(discord.ui.View): + """Self-only candidate selector for onboarding email generation.""" + + def __init__( + self, + *, + cog: "OnboardingEmailCog", + requester_id: int, + state: OnboardingEmailCommandState, + contacts: list[dict[str, Any]], + ) -> None: + super().__init__(timeout=300) + self.cog = cog + self.requester_id = requester_id + self.state = state + self.add_item(OnboardingEmailContactSelect(contacts)) + + async def interaction_check(self, interaction: discord.Interaction) -> bool: + if interaction.user.id != self.requester_id: + await interaction.response.send_message( + "❌ Only the command requester can select the candidate.", + allowed_mentions=NO_MENTIONS, + ephemeral=True, + ) + return False + return True + + +class OnboardingEmailCog(DiscordAuditCogMixin, commands.Cog): + """Generate and optionally send 508 onboarding emails from Discord.""" + + def __init__(self, bot: commands.Bot) -> None: + self.bot = bot + self.crm = EspoClient(settings.espo_base_url, settings.espo_api_key) + self._init_audit_logger() + + @staticmethod + def _display_name(interaction: discord.Interaction) -> str: + display_name = getattr(interaction.user, "display_name", None) + if display_name: + return OnboardingEmailCog._normalized_person_name(str(display_name)) + name = getattr(interaction.user, "name", None) + if name: + return OnboardingEmailCog._normalized_person_name(str(name)) + return "508.dev" + + @staticmethod + def _normalized_person_name(value: str) -> str: + normalized = " ".join(value.strip().split()) + return normalized or "508.dev" + + @staticmethod + def _first_name(value: str) -> str: + return OnboardingEmailCog._normalized_person_name(value).split(" ", 1)[0] + + @staticmethod + def _validate_email(value: str, field_name: str) -> str: + normalized = value.strip() + parsed_name, parsed_email = parseaddr(normalized) + if parsed_name or parsed_email != normalized: + raise ValueError(f"{field_name} must be a plain email address.") + if not EMAIL_RE.fullmatch(normalized): + raise ValueError(f"{field_name} must be a valid email address.") + return normalized + + def _reply_to_email_for_user( + self, + *, + interaction: discord.Interaction, + override: str | None, + ) -> str | None: + if override: + return self._validate_email(override, "reply_to_email") + + contacts = self._contacts_for_discord_user_id( + interaction, + select="id,name,emailAddress,c508Email,cDiscordUserID", + ) + if not contacts: + return None + contact = contacts[0] + + for field_name in ("c508Email", "emailAddress"): + candidate = str(contact.get(field_name) or "").strip() + if candidate and EMAIL_RE.fullmatch(candidate): + return candidate + return None + + def _contacts_for_discord_user_id( + self, + interaction: discord.Interaction, + *, + select: str, + ) -> list[dict[str, Any]]: + discord_user_id = str(interaction.user.id) + response = self.crm.list_contacts( + { + "where": [ + { + "type": "equals", + "attribute": "cDiscordUserID", + "value": discord_user_id, + } + ], + "maxSize": 1, + "select": select, + } + ) + contacts = response.get("list", []) + if isinstance(contacts, list) and contacts: + contact = contacts[0] + if ( + isinstance(contact, dict) + and str(contact.get("cDiscordUserID") or "").strip() == discord_user_id + ): + return [contact] + return [] + + def _contacts_for_discord_username( + self, + interaction: discord.Interaction, + *, + select: str, + ) -> list[dict[str, Any]]: + username_candidates = { + str(getattr(interaction.user, "name", "") or "").strip(), + str(getattr(interaction.user, "display_name", "") or "").strip(), + } + for username in sorted(value for value in username_candidates if value): + response = self.crm.list_contacts( + { + "where": [ + { + "type": "equals", + "attribute": "cDiscordUsername", + "value": username, + } + ], + "maxSize": 2, + "select": select, + } + ) + contacts = response.get("list", []) + if not isinstance(contacts, list) or len(contacts) != 1: + continue + contact = contacts[0] + if isinstance(contact, dict): + return [contact] + return [] + + def _sender_identity_for_user( + self, interaction: discord.Interaction + ) -> tuple[str, str] | None: + contacts = self._contacts_for_discord_user_id( + interaction, + select="id,name,c508Email,cDiscordUserID,cDiscordUsername", + ) + if not contacts: + contacts = self._contacts_for_discord_username( + interaction, + select="id,name,c508Email,cDiscordUserID,cDiscordUsername", + ) + if not contacts: + return None + contact = contacts[0] + crm_name = self._normalized_person_name(str(contact.get("name") or "")) + if crm_name != "508.dev": + return crm_name, self._first_name(crm_name) + crm_username = self._normalize_508_username(contact.get("c508Email")) + if crm_username: + return crm_username, self._first_name(crm_username) + return None + + @staticmethod + def _normalize_508_username(value: object) -> str | None: + raw_value = str(value or "").strip().lower() + if not raw_value: + return None + if "@" in raw_value: + local_part, _, domain = raw_value.partition("@") + if domain != "508.dev": + return None + raw_value = local_part + normalized = raw_value.strip() + if not normalized or any(char.isspace() for char in normalized): + return None + return normalized + + @staticmethod + def _has_steering_committee_access(interaction: discord.Interaction) -> bool: + roles = getattr(interaction.user, "roles", None) + if not roles: + return False + return check_user_roles_with_hierarchy(roles, ["Steering Committee"]) + + def _requester_508_username(self, interaction: discord.Interaction) -> str | None: + contacts = self._contacts_for_discord_user_id( + interaction, + select="id,name,c508Email,cDiscordUserID", + ) + if not contacts: + return None + contact = contacts[0] + return self._normalize_508_username(contact.get("c508Email")) + + @staticmethod + def _normalize_onboarding_status(value: object) -> str: + return str(value or "").strip().lower() + + @staticmethod + def _preferred_contact_email(contact: dict[str, Any]) -> str | None: + for field_name in ("emailAddress", "c508Email"): + candidate = str(contact.get(field_name) or "").strip() + if candidate and EMAIL_RE.fullmatch(candidate): + return candidate + return None + + @staticmethod + def _contact_display_name(contact: dict[str, Any]) -> str: + return str(contact.get("name") or "Unknown").strip() or "Unknown" + + def _search_candidate_contacts( + self, + *, + candidate_name: str, + recipient_email: str | None, + ) -> list[dict[str, Any]]: + filters: list[dict[str, Any]] = [] + if recipient_email: + filters.extend( + [ + { + "type": "equals", + "attribute": "emailAddress", + "value": recipient_email, + }, + { + "type": "equals", + "attribute": "c508Email", + "value": recipient_email, + }, + ] + ) + else: + search_term = candidate_name.strip() + if search_term: + filters.append( + { + "type": "contains", + "attribute": "name", + "value": search_term, + } + ) + if EMAIL_RE.fullmatch(search_term): + filters.extend( + [ + { + "type": "equals", + "attribute": "emailAddress", + "value": search_term, + }, + { + "type": "equals", + "attribute": "c508Email", + "value": search_term, + }, + ] + ) + + if not filters: + return [] + + response = self.crm.list_contacts( + { + "where": [{"type": "or", "value": filters}], + "maxSize": 25, + "select": ONBOARDING_EMAIL_CONTACT_SELECT_FIELDS, + } + ) + contacts = response.get("list", []) + if not isinstance(contacts, list): + return [] + + deduplicated: list[dict[str, Any]] = [] + seen_ids: set[str] = set() + for contact in contacts: + if not isinstance(contact, dict): + continue + contact_id = str(contact.get("id") or "").strip() + if not contact_id or contact_id in seen_ids: + continue + seen_ids.add(contact_id) + deduplicated.append(contact) + return deduplicated + + def _authorize_onboarding_email( + self, + *, + interaction: discord.Interaction, + selected_contact: dict[str, Any] | None, + ) -> str: + """Return the authorization source or raise when the actor may not proceed.""" + if self._has_steering_committee_access(interaction): + return "steering_committee" + + if selected_contact is None: + raise PermissionError( + "Only Steering Committee+ can use this command without " + "a unique CRM candidate match. Designated onboarders must provide " + "or select a candidate so the CRM assignment can be verified." + ) + + requester_username = self._requester_508_username(interaction) + if requester_username is None: + raise PermissionError( + "Only Steering Committee+ or the candidate's designated onboarder " + "can use this command. Your Discord account is not linked to a " + "CRM contact with a 508 email." + ) + + assigned_onboarder = self._normalize_508_username( + selected_contact.get(ONBOARDER_FIELD) + ) + if assigned_onboarder != requester_username: + raise PermissionError( + "Only Steering Committee+ or the candidate's designated onboarder " + "can use this command." + ) + return "designated_onboarder" + + def _authorized_candidate_contacts_for_actor( + self, + *, + interaction: discord.Interaction, + contacts: list[dict[str, Any]], + ) -> list[dict[str, Any]]: + if self._has_steering_committee_access(interaction): + return contacts + + requester_username = self._requester_508_username(interaction) + if requester_username is None: + return [] + + return [ + contact + for contact in contacts + if self._normalize_508_username(contact.get(ONBOARDER_FIELD)) + == requester_username + ] + + @staticmethod + def _public_error_text(exc: Exception) -> str: + text = " ".join(str(exc).split()) + return text.replace("`", "'") or "Could not prepare the onboarding email." + + def _classify_onboarding_email_error(self, exc: Exception) -> tuple[str, str, str]: + if isinstance(exc, PermissionError): + return "denied", "permission_denied", f"⚠️ {self._public_error_text(exc)}" + if isinstance(exc, EspoAPIError): + return ( + "error", + "crm_lookup_failed", + "❌ CRM lookup failed while preparing the onboarding email. " + "Try again or ask an admin to check CRM.", + ) + if isinstance(exc, (OSError, smtplib.SMTPException)): + return ( + "error", + "smtp_send_failed", + "❌ Failed to send the onboarding email.", + ) + if isinstance(exc, ValueError): + return "error", "validation_error", f"⚠️ {self._public_error_text(exc)}" + return ( + "error", + "unexpected_error", + "❌ Could not prepare the onboarding email.", + ) + + async def _handle_onboarding_email_error( + self, + interaction: discord.Interaction, + exc: Exception, + *, + state: OnboardingEmailCommandState | None, + candidate_name: str, + recipient_email: str | None, + has_contributed: bool, + discord_joined: str, + agreement_signed: str, + send: bool, + selected_contact: dict[str, Any] | None = None, + ) -> None: + result, error_code, public_message = self._classify_onboarding_email_error(exc) + if result == "error": + logger.warning("Onboarding email command failed: %s", exc, exc_info=True) + + contact_id = ( + str(selected_contact.get("id") or "").strip() + if selected_contact is not None + else None + ) + self._audit_command_safe( + interaction=interaction, + action="onboarding.email", + result=result, + metadata={ + "candidate_name": candidate_name, + "contact_id": contact_id, + "recipient_email": recipient_email, + "has_contributed": has_contributed, + "discord_joined": discord_joined, + "agreement_signed": agreement_signed, + "send": send, + "error": error_code, + "error_type": type(exc).__name__, + "sender_display_name": ( + state.sender_display_name if state is not None else None + ), + "signature_name": state.signature_name if state is not None else None, + }, + resource_type="crm_contact" if contact_id else "onboarding_email", + resource_id=contact_id or recipient_email, + ) + await interaction.followup.send( + public_message, + allowed_mentions=NO_MENTIONS, + ephemeral=True, + ) + + def _build_message( + self, + *, + recipient_email: str, + reply_to_email: str, + sender_name: str, + subject: str, + text_body: str, + html_body: str, + ) -> EmailMessage: + sender_email = self._validate_email( + settings.onboarding_email_sender_email, + "ONBOARDING_EMAIL_SENDER_EMAIL", + ) + message = EmailMessage() + message["Subject"] = subject + message["From"] = formataddr((sender_name, sender_email)) + message["To"] = recipient_email + message["Reply-To"] = formataddr((sender_name, reply_to_email)) + message.set_content(text_body) + message.add_alternative(html_body, subtype="html") + return message + + def _send_message(self, message: EmailMessage) -> None: + smtp_server = (settings.onboarding_email_smtp_server or "").strip() + smtp_username = (settings.onboarding_email_smtp_username or "").strip() + smtp_password = (settings.onboarding_email_smtp_password or "").strip() + if not smtp_server: + raise ValueError("ONBOARDING_EMAIL_SMTP_SERVER is required to send.") + if not smtp_username or not smtp_password: + raise ValueError( + "ONBOARDING_EMAIL_SMTP_USERNAME and " + "ONBOARDING_EMAIL_SMTP_PASSWORD are required to send." + ) + if ( + not settings.onboarding_email_smtp_use_ssl + and not settings.onboarding_email_smtp_starttls + ): + raise ValueError( + "Onboarding email SMTP requires TLS. Enable " + "ONBOARDING_EMAIL_SMTP_USE_SSL or ONBOARDING_EMAIL_SMTP_STARTTLS." + ) + + port = settings.onboarding_email_smtp_port + timeout = settings.onboarding_email_smtp_timeout_seconds + tls_context = ssl.create_default_context() + if settings.onboarding_email_smtp_use_ssl: + with smtplib.SMTP_SSL( + smtp_server, + port, + timeout=timeout, + context=tls_context, + ) as smtp: + smtp.login(smtp_username, smtp_password) + smtp.send_message(message) + return + + with smtplib.SMTP(smtp_server, port, timeout=timeout) as smtp: + if settings.onboarding_email_smtp_starttls: + smtp.starttls(context=tls_context) + smtp.login(smtp_username, smtp_password) + smtp.send_message(message) + + async def _send_draft_response( + self, + interaction: discord.Interaction, + *, + summary: str, + markdown_body: str, + ) -> None: + draft_heading = "**Copy/paste draft:**" + combined = f"{summary}\n\n{draft_heading}\n{markdown_body}" + if len(combined) <= 2000: + await interaction.followup.send( + combined, + allowed_mentions=NO_MENTIONS, + ephemeral=True, + ) + return + + if len(summary) <= 2000: + await interaction.followup.send( + summary, + allowed_mentions=NO_MENTIONS, + ephemeral=True, + ) + else: + chunks = [ + summary[index : index + 1900] for index in range(0, len(summary), 1900) + ] + for index, chunk in enumerate(chunks, 1): + await interaction.followup.send( + f"**Summary ({index}/{len(chunks)}):**\n{chunk}", + allowed_mentions=NO_MENTIONS, + ephemeral=True, + ) + + if len(f"{draft_heading}\n{markdown_body}") <= 2000: + await interaction.followup.send( + f"{draft_heading}\n{markdown_body}", + allowed_mentions=NO_MENTIONS, + ephemeral=True, + ) + return + + chunks = [ + markdown_body[index : index + 1900] + for index in range(0, len(markdown_body), 1900) + ] + for index, chunk in enumerate(chunks, 1): + await interaction.followup.send( + f"**Copy/paste draft ({index}/{len(chunks)}):**\n{chunk}", + allowed_mentions=NO_MENTIONS, + ephemeral=True, + ) + + async def _complete_onboarding_email( + self, + interaction: discord.Interaction, + *, + state: OnboardingEmailCommandState, + selected_contact: dict[str, Any] | None, + ) -> None: + candidate_name = ( + self._contact_display_name(selected_contact) + if selected_contact is not None + else state.candidate_name + ) + contact_status = ( + self._normalize_onboarding_status( + selected_contact.get(ONBOARDING_STATUS_FIELD) + ) + if selected_contact is not None + else "" + ) + contact_id = ( + str(selected_contact.get("id") or "").strip() + if selected_contact is not None + else None + ) + if contact_status in ALREADY_ONBOARDED_STATES: + self._audit_command_safe( + interaction=interaction, + action="onboarding.email", + result="denied", + metadata={ + "candidate_name": candidate_name, + "contact_id": contact_id, + "recipient_email": state.recipient_email, + "onboarding_status": contact_status, + "send": state.send, + "error": "candidate_already_onboarded", + }, + resource_type="crm_contact", + resource_id=contact_id, + ) + await interaction.followup.send( + f"⚠️ **{candidate_name}** is already onboarded. " + "No onboarding email was generated or sent.", + allowed_mentions=NO_MENTIONS, + ephemeral=True, + ) + return + + normalized_recipient = state.recipient_email + if normalized_recipient is None and selected_contact is not None: + normalized_recipient = self._preferred_contact_email(selected_contact) + + authorization_source = await asyncio.to_thread( + self._authorize_onboarding_email, + interaction=interaction, + selected_contact=selected_contact, + ) + + draft = build_onboarding_email( + OnboardingEmailRequest( + candidate_name=candidate_name, + sender_name=state.signature_name, + has_contributed=state.has_contributed, + discord_joined=state.discord_joined, # type: ignore[arg-type] + membership_agreement_signed=state.agreement_signed, # type: ignore[arg-type] + ) + ) + try: + resolved_reply_to = await asyncio.to_thread( + self._reply_to_email_for_user, + interaction=interaction, + override=state.reply_to_email, + ) + except EspoAPIError: + if state.send and not state.reply_to_email: + raise + logger.warning( + "Unable to resolve onboarding email Reply-To from CRM", + exc_info=True, + ) + resolved_reply_to = None + + if state.send and normalized_recipient is None: + raise ValueError( + "recipient_email is required when send is true, or the selected " + "CRM contact must have an email." + ) + if state.send and resolved_reply_to is None: + raise ValueError( + "reply_to_email is required because your Discord user does not " + "have a CRM-linked email." + ) + + if state.send and normalized_recipient and resolved_reply_to: + message = self._build_message( + recipient_email=normalized_recipient, + reply_to_email=resolved_reply_to, + sender_name=state.sender_display_name, + subject=draft.subject, + text_body=draft.text_body, + html_body=draft.html_body, + ) + await asyncio.to_thread(self._send_message, message) + email_action = "sent" + heading = "✅ Onboarding email sent." + else: + email_action = "drafted" + heading = "📝 Onboarding email draft generated." + + self._audit_command_safe( + interaction=interaction, + action="onboarding.email", + result="success", + metadata={ + "email_action": email_action, + "candidate_name": candidate_name, + "contact_id": contact_id, + "recipient_email": normalized_recipient, + "has_contributed": state.has_contributed, + "discord_joined": state.discord_joined, + "agreement_signed": state.agreement_signed, + "sender_display_name": state.sender_display_name, + "signature_name": state.signature_name, + "reply_to_email": resolved_reply_to, + "authorization_source": authorization_source, + "onboarding_status": contact_status or None, + }, + resource_type="crm_contact" if contact_id else "onboarding_email", + resource_id=contact_id or normalized_recipient, + ) + reply_to_line = resolved_reply_to or "not resolved" + lines = [ + heading, + f"Subject: `{draft.subject}`", + ( + "From: " + f"`{state.sender_display_name} " + f"<{settings.onboarding_email_sender_email}>`" + ), + f"Reply-To: `{reply_to_line}`", + ] + if selected_contact is not None: + status_line = contact_status or "unknown" + lines.append( + f"CRM contact: `{candidate_name}` (`{contact_id or 'unknown'}`), " + f"status: `{status_line}`" + ) + await self._send_draft_response( + interaction, + summary="\n".join(lines), + markdown_body=draft.markdown_body, + ) + + async def _run_onboarding_email_flow( + self, + interaction: discord.Interaction, + *, + state: OnboardingEmailCommandState, + selected_contact: dict[str, Any] | None, + ) -> None: + try: + await self._complete_onboarding_email( + interaction, + state=state, + selected_contact=selected_contact, + ) + except ( + PermissionError, + ValueError, + EspoAPIError, + OSError, + smtplib.SMTPException, + ) as exc: + await self._handle_onboarding_email_error( + interaction, + exc, + state=state, + candidate_name=state.candidate_name, + recipient_email=state.recipient_email, + has_contributed=state.has_contributed, + discord_joined=state.discord_joined, + agreement_signed=state.agreement_signed, + send=state.send, + selected_contact=selected_contact, + ) + + @app_commands.command( + name="onboarding-email", + description="Draft or send a 508 candidate onboarding email.", + ) + @app_commands.describe( + candidate_name="Candidate name or email search term. CRM name is used when matched.", + has_contributed="Whether the candidate has completed the contribution requirement.", + recipient_email="Required when send is true.", + discord_joined="Whether the candidate has already joined Discord.", + agreement_signed="Whether the membership agreement is already signed.", + sender_name="Name to use in From display and signature. Defaults to your Discord name.", + reply_to_email="Reply-To address. Defaults from your CRM-linked 508 email when available.", + send="Actually send through configured SMTP. Defaults to false.", + ) + @app_commands.choices( + discord_joined=[ + app_commands.Choice(name="Unknown", value="unknown"), + app_commands.Choice(name="Yes", value="yes"), + app_commands.Choice(name="No", value="no"), + ], + agreement_signed=[ + app_commands.Choice(name="Unknown", value="unknown"), + app_commands.Choice(name="Yes", value="yes"), + app_commands.Choice(name="No", value="no"), + ], + ) + async def onboarding_email( + self, + interaction: discord.Interaction, + candidate_name: str, + has_contributed: bool, + recipient_email: str | None = None, + discord_joined: str = "unknown", + agreement_signed: str = "unknown", + sender_name: str | None = None, + reply_to_email: str | None = None, + send: bool = False, + ) -> None: + """Generate a candidate onboarding email draft and optionally send it.""" + await interaction.response.defer(ephemeral=True) + state: OnboardingEmailCommandState | None = None + + try: + override_sender_name = sender_name.strip() if sender_name else "" + if override_sender_name: + sender_display_name = self._normalized_person_name(override_sender_name) + signature_name = self._first_name(sender_display_name) + else: + try: + sender_identity = await asyncio.to_thread( + self._sender_identity_for_user, + interaction, + ) + except EspoAPIError: + logger.warning( + "Unable to resolve onboarding email sender name from CRM", + exc_info=True, + ) + sender_identity = None + if sender_identity is not None: + sender_display_name, signature_name = sender_identity + else: + sender_display_name = self._display_name(interaction) + signature_name = self._first_name(sender_display_name) + + normalized_recipient = ( + self._validate_email(recipient_email, "recipient_email") + if recipient_email + else None + ) + state = OnboardingEmailCommandState( + candidate_name=candidate_name, + has_contributed=has_contributed, + recipient_email=normalized_recipient, + discord_joined=discord_joined, + agreement_signed=agreement_signed, + sender_display_name=sender_display_name, + signature_name=signature_name, + reply_to_email=reply_to_email, + send=send, + ) + + try: + candidate_contacts = await asyncio.to_thread( + self._search_candidate_contacts, + candidate_name=candidate_name, + recipient_email=normalized_recipient, + ) + except EspoAPIError: + if ( + normalized_recipient + or send + or not self._has_steering_committee_access(interaction) + ): + raise + logger.warning( + "Unable to search onboarding email candidate contacts", + exc_info=True, + ) + candidate_contacts = [] + if candidate_contacts: + candidate_contacts = await asyncio.to_thread( + self._authorized_candidate_contacts_for_actor, + interaction=interaction, + contacts=candidate_contacts, + ) + if not candidate_contacts: + raise PermissionError( + "Only Steering Committee+ or the candidate's designated " + "onboarder can use this command." + ) + if len(candidate_contacts) > 1: + view = OnboardingEmailContactSelectView( + cog=self, + requester_id=interaction.user.id, + state=state, + contacts=candidate_contacts, + ) + await interaction.followup.send( + "⚠️ Multiple CRM contacts match this onboarding email. " + "Select the candidate to continue. Already-onboarded contacts " + "are labelled with `status: onboarded`.", + allowed_mentions=NO_MENTIONS, + view=view, + ephemeral=True, + ) + return + + selected_contact = candidate_contacts[0] if candidate_contacts else None + if normalized_recipient and selected_contact is None: + if not self._has_steering_committee_access(interaction): + raise PermissionError( + "Only Steering Committee+ or the candidate's designated " + "onboarder can use this command." + ) + raise ValueError("No CRM contact found for recipient_email.") + + await self._run_onboarding_email_flow( + interaction, + state=state, + selected_contact=selected_contact, + ) + except ( + PermissionError, + ValueError, + EspoAPIError, + OSError, + smtplib.SMTPException, + ) as exc: + await self._handle_onboarding_email_error( + interaction, + exc, + state=state, + candidate_name=candidate_name, + recipient_email=recipient_email, + has_contributed=has_contributed, + discord_joined=discord_joined, + agreement_signed=agreement_signed, + send=send, + ) + + +async def setup(bot: commands.Bot) -> None: + """Add the onboarding email cog to the bot.""" + await bot.add_cog(OnboardingEmailCog(bot)) diff --git a/apps/discord_bot/src/five08/discord_bot/config.py b/apps/discord_bot/src/five08/discord_bot/config.py index 76311acc..2013dc58 100644 --- a/apps/discord_bot/src/five08/discord_bot/config.py +++ b/apps/discord_bot/src/five08/discord_bot/config.py @@ -61,6 +61,62 @@ class Settings(SharedSettings): resume_ai_base_url: str | None = None resume_ai_model: str = "gpt-4.1-mini" resume_extractor_max_tokens: int = 2000 + onboarding_email_smtp_server: str | None = Field( + default=None, + validation_alias=AliasChoices( + "ONBOARDING_EMAIL_SMTP_SERVER", + "SMTP_SERVER", + "onboarding_email_smtp_server", + "smtp_server", + ), + ) + onboarding_email_smtp_port: int = Field( + default=465, + validation_alias=AliasChoices( + "ONBOARDING_EMAIL_SMTP_PORT", + "SMTP_PORT", + "onboarding_email_smtp_port", + "smtp_port", + ), + ) + onboarding_email_smtp_use_ssl: bool = Field( + default=True, + validation_alias=AliasChoices( + "ONBOARDING_EMAIL_SMTP_USE_SSL", + "SMTP_USE_SSL", + "onboarding_email_smtp_use_ssl", + "smtp_use_ssl", + ), + ) + onboarding_email_smtp_starttls: bool = Field( + default=False, + validation_alias=AliasChoices( + "ONBOARDING_EMAIL_SMTP_STARTTLS", + "SMTP_STARTTLS", + "onboarding_email_smtp_starttls", + "smtp_starttls", + ), + ) + onboarding_email_smtp_username: str | None = Field( + default=None, + validation_alias=AliasChoices( + "ONBOARDING_EMAIL_SMTP_USERNAME", + "EMAIL_USERNAME", + "onboarding_email_smtp_username", + "email_username", + ), + ) + onboarding_email_smtp_password: str | None = Field( + default=None, + validation_alias=AliasChoices( + "ONBOARDING_EMAIL_SMTP_PASSWORD", + "EMAIL_PASSWORD", + "onboarding_email_smtp_password", + "email_password", + ), + ) + onboarding_email_sender_email: str = "onboarding@508.dev" + onboarding_email_smtp_timeout_seconds: float = 20.0 # Kimai time tracking settings kimai_base_url: str diff --git a/packages/shared/src/five08/onboarding_email.py b/packages/shared/src/five08/onboarding_email.py new file mode 100644 index 00000000..5a12968e --- /dev/null +++ b/packages/shared/src/five08/onboarding_email.py @@ -0,0 +1,311 @@ +"""Deterministic 508 onboarding email draft generation.""" + +from __future__ import annotations + +from dataclasses import dataclass +from html import escape +from typing import Literal + +TriState = Literal["yes", "no", "unknown"] + +DISCORD_INVITE_URL = "https://discord.gg/9zAKxmUZJf" +PROSPECTIVE_MEMBERS_CHANNEL_URL = ( + "https://discord.com/channels/1336096360772141148/1336628706160017469" +) +CONTRIBUTION_REQUIREMENT_URL = "https://wiki.508.dev/s/contributing-to-508" +MEMBER_AGREEMENT_URL = "https://wiki.508.dev/s/values/doc/member-agreement-BIZFA9Xfi4" +WIKI_URL = "https://wiki.508.dev/" +ONBOARDING_INSTRUCTIONS_URL = "https://wiki.508.dev/s/onboarding" + +TRI_STATE_VALUES: frozenset[str] = frozenset({"yes", "no", "unknown"}) + + +@dataclass(frozen=True, slots=True) +class OnboardingEmailRequest: + """Inputs that determine the onboarding email sections.""" + + candidate_name: str + sender_name: str + has_contributed: bool + discord_joined: TriState = "unknown" + membership_agreement_signed: TriState = "unknown" + + +@dataclass(frozen=True, slots=True) +class OnboardingEmailDraft: + """Rendered email suitable for previewing or sending.""" + + subject: str + text_body: str + markdown_body: str + html_body: str + + +def build_onboarding_email(request: OnboardingEmailRequest) -> OnboardingEmailDraft: + """Build the onboarding email draft from explicit candidate state.""" + candidate_name = _required_text(request.candidate_name, "candidate_name") + sender_name = _required_text(request.sender_name, "sender_name") + discord_joined = _tri_state(request.discord_joined, "discord_joined") + agreement_signed = _tri_state( + request.membership_agreement_signed, + "membership_agreement_signed", + ) + + if request.has_contributed: + paragraphs = _new_member_paragraphs( + discord_joined=discord_joined, + agreement_signed=agreement_signed, + ) + else: + paragraphs = _prospective_member_paragraphs( + discord_joined=discord_joined, + agreement_signed=agreement_signed, + ) + + candidate_first_name = _first_name(candidate_name) + text_lines = ["Great Talking,", candidate_first_name, ""] + markdown_lines = ["Great Talking,", candidate_first_name, ""] + html_paragraphs = [f"Great Talking,
{escape(candidate_first_name)}"] + for paragraph in paragraphs: + text_lines.append(_render_text_paragraph(paragraph)) + text_lines.append("") + markdown_lines.append(_render_markdown_paragraph(paragraph)) + markdown_lines.append("") + html_paragraphs.append(_render_html_paragraph(paragraph)) + text_lines.extend(["Cheers,", sender_name]) + markdown_lines.extend(["Cheers,", sender_name]) + html_paragraphs.append(f"Cheers,
{escape(sender_name)}") + + return OnboardingEmailDraft( + subject="508.dev onboarding", + text_body="\n".join(text_lines).strip() + "\n", + markdown_body="\n".join(markdown_lines).strip() + "\n", + html_body=_render_html_document(html_paragraphs), + ) + + +def _prospective_member_paragraphs( + *, + discord_joined: TriState, + agreement_signed: TriState, +) -> list[list[tuple[str, str | None]]]: + paragraphs: list[list[tuple[str, str | None]]] = [ + [("The main part of the 508 community is our Discord server.", None)], + ] + if discord_joined == "yes": + paragraphs.append( + [ + ( + "Since you have already joined, you should be limited to the ", + None, + ), + ("#prospective-members", PROSPECTIVE_MEMBERS_CHANNEL_URL), + ( + " channel for now. Feel free to ask any questions there.", + None, + ), + ] + ) + else: + paragraphs.append( + [ + ("The invite link you will need is the ", None), + ("508 Discord server", DISCORD_INVITE_URL), + ( + ". When you join, you will be limited to the ", + None, + ), + ("#prospective-members", PROSPECTIVE_MEMBERS_CHANNEL_URL), + ( + " channel. Feel free to ask any questions there.", + None, + ), + ] + ) + + paragraphs.append( + [ + ( + "In order to be a full member, we have a contribution requirement: ", + None, + ), + ("contributing to 508", CONTRIBUTION_REQUIREMENT_URL), + (".", None), + ] + ) + if agreement_signed != "yes": + paragraphs.append( + [ + ("You will also need to sign a ", None), + ("member agreement", MEMBER_AGREEMENT_URL), + ( + ", which we will send to you after the contribution requirement.", + None, + ), + ] + ) + return paragraphs + + +def _new_member_paragraphs( + *, + discord_joined: TriState, + agreement_signed: TriState, +) -> list[list[tuple[str, str | None]]]: + paragraphs: list[list[tuple[str, str | None]]] = [ + [("The main part of the 508 community is our Discord server.", None)], + ] + if discord_joined == "yes": + paragraphs.append( + [ + ( + "Since you have already joined Discord, you may need to wait for " + "an admin to give you the Member role to see all channels in the " + "server.", + None, + ) + ] + ) + else: + paragraphs.append( + [ + ("The invite link you will need is the ", None), + ("508 Discord server", DISCORD_INVITE_URL), + ( + ". When you join, you will be limited to the ", + None, + ), + ("#prospective-members", PROSPECTIVE_MEMBERS_CHANNEL_URL), + ( + " channel. You may need to wait for an admin to give you the " + "Member role to see all channels in the server.", + None, + ), + ] + ) + + paragraphs.append( + [ + ( + "Once you have the Member role and can see all channels, make sure " + "to introduce yourself in #new-members, and use the #roles channel " + "to mark your technical expertise.", + None, + ) + ] + ) + if agreement_signed != "yes": + paragraphs.append( + [ + ( + "You will need to sign a membership agreement to fully onboard. " + "Look out for one in your email, or ask your 508 contact if you " + "do not see it.", + None, + ) + ] + ) + paragraphs.extend( + [ + [ + ( + "DM @caleb or @michaelmwu on Discord with the @508.dev email " + "you want, and a backup email to send the invitation to.", + None, + ) + ], + [ + ("You will be given access to our ", None), + ("wiki", WIKI_URL), + ( + " using that email address to log in. If not, please ask an " + "admin to give you access.", + None, + ), + ], + [ + ( + "As you get settled in, feel free to ask questions in the " + "Discord server, particularly the #new-members channel, " + "especially if anything goes wrong in onboarding. Or feel free " + "to email me.", + None, + ) + ], + [ + ("After getting set up with the wiki, please read the ", None), + ("onboarding instructions", ONBOARDING_INSTRUCTIONS_URL), + ( + ". The wiki is a great place to learn information in general " + "about 508.dev as well.", + None, + ), + ], + ] + ) + return paragraphs + + +def _render_text_paragraph(parts: list[tuple[str, str | None]]) -> str: + output: list[str] = [] + for label, url in parts: + output.append(label) + if url is not None: + output.append(f" ({url})") + return "".join(output) + + +def _render_markdown_paragraph(parts: list[tuple[str, str | None]]) -> str: + output: list[str] = [] + for label, url in parts: + if url is None: + output.append(label) + else: + escaped_label = label.replace("[", r"\[").replace("]", r"\]") + output.append(f"[{escaped_label}]({url})") + return "".join(output) + + +def _render_html_paragraph(parts: list[tuple[str, str | None]]) -> str: + output: list[str] = [] + for label, url in parts: + if url is None: + output.append(escape(label)) + else: + output.append(f'{escape(label)}') + return "".join(output) + + +def _render_html_document(paragraphs: list[str]) -> str: + body = "\n".join(f"

{paragraph}

" for paragraph in paragraphs) + return ( + "\n" + '\n' + "\n" + ' \n' + " 508.dev onboarding\n" + "\n" + "\n" + f"{body}\n" + "\n" + "\n" + ) + + +def _required_text(value: str, field_name: str) -> str: + normalized = " ".join(value.strip().split()) + if not normalized: + raise ValueError(f"{field_name} is required") + return normalized + + +def _first_name(value: str) -> str: + normalized = _required_text(value, "candidate_name") + return normalized.split(" ", 1)[0] + + +def _tri_state(value: str, field_name: str) -> TriState: + normalized = value.strip().lower() + if normalized not in TRI_STATE_VALUES: + raise ValueError(f"{field_name} must be yes, no, or unknown") + return normalized # type: ignore[return-value] diff --git a/tests/unit/test_bot.py b/tests/unit/test_bot.py index f6745ce5..a80a9781 100644 --- a/tests/unit/test_bot.py +++ b/tests/unit/test_bot.py @@ -157,6 +157,29 @@ def test_backend_api_base_url_defaults_to_host_runtime( assert config.backend_api_base_url == "http://127.0.0.1:8090" + def test_onboarding_email_smtp_settings_fall_back_to_generic_smtp_env( + self, + monkeypatch: pytest.MonkeyPatch, + ): + monkeypatch.delenv("ONBOARDING_EMAIL_SMTP_PORT", raising=False) + monkeypatch.delenv("ONBOARDING_EMAIL_SMTP_USE_SSL", raising=False) + monkeypatch.delenv("ONBOARDING_EMAIL_SMTP_STARTTLS", raising=False) + monkeypatch.setenv("SMTP_PORT", "587") + monkeypatch.setenv("SMTP_USE_SSL", "false") + monkeypatch.setenv("SMTP_STARTTLS", "true") + + config = Settings( + discord_bot_token="token", + espo_api_key="espo-key", + espo_base_url="https://crm.example.com", + kimai_base_url="https://kimai.example.com", + kimai_api_token="kimai-token", + ) + + assert config.onboarding_email_smtp_port == 587 + assert config.onboarding_email_smtp_use_ssl is False + assert config.onboarding_email_smtp_starttls is True + def test_validate_app_command_descriptions_accepts_valid_lengths(self): """Test that valid command descriptions pass validation.""" tree = Mock() diff --git a/tests/unit/test_onboarding_email.py b/tests/unit/test_onboarding_email.py new file mode 100644 index 00000000..04f386a2 --- /dev/null +++ b/tests/unit/test_onboarding_email.py @@ -0,0 +1,72 @@ +"""Tests for deterministic onboarding email generation.""" + +from __future__ import annotations + +import pytest + +from five08.onboarding_email import OnboardingEmailRequest, build_onboarding_email + + +def test_prospective_member_email_includes_contribution_but_not_member_steps() -> None: + draft = build_onboarding_email( + OnboardingEmailRequest( + candidate_name=" Jane Example ", + sender_name="Michael Wu", + has_contributed=False, + discord_joined="no", + membership_agreement_signed="unknown", + ) + ) + + assert draft.subject == "508.dev onboarding" + assert draft.text_body.startswith("Great Talking,\nJane\n\n") + assert draft.markdown_body.startswith("Great Talking,\nJane\n\n") + assert "contribution requirement" in draft.text_body + assert "https://wiki.508.dev/s/contributing-to-508" in draft.text_body + assert ( + "[contributing to 508](https://wiki.508.dev/s/contributing-to-508)" + in draft.markdown_body + ) + assert ( + "[#prospective-members](https://discord.com/channels/1336096360772141148/1336628706160017469)" + in draft.markdown_body + ) + assert "DM @caleb or @michaelmwu" not in draft.text_body + assert '508 Discord server' in ( + draft.html_body + ) + assert "

Great Talking,
Jane

" in draft.html_body + assert "

Cheers,
Michael Wu

" in draft.html_body + + +def test_new_member_email_omits_invite_and_agreement_when_already_done() -> None: + draft = build_onboarding_email( + OnboardingEmailRequest( + candidate_name="Sam Member", + sender_name="Caleb", + has_contributed=True, + discord_joined="yes", + membership_agreement_signed="yes", + ) + ) + + assert "Since you have already joined Discord" in draft.text_body + assert "https://discord.gg/9zAKxmUZJf" not in draft.text_body + assert "membership agreement to fully onboard" not in draft.text_body + assert "DM @caleb or @michaelmwu" in draft.text_body + assert "[onboarding instructions](https://wiki.508.dev/s/onboarding)" in ( + draft.markdown_body + ) + assert '' in draft.html_body + + +def test_invalid_state_is_rejected() -> None: + with pytest.raises(ValueError, match="discord_joined"): + build_onboarding_email( + OnboardingEmailRequest( + candidate_name="Sam", + sender_name="Michael", + has_contributed=True, + discord_joined="maybe", # type: ignore[arg-type] + ) + ) diff --git a/tests/unit/test_onboarding_email_cog.py b/tests/unit/test_onboarding_email_cog.py new file mode 100644 index 00000000..1cce936a --- /dev/null +++ b/tests/unit/test_onboarding_email_cog.py @@ -0,0 +1,718 @@ +"""Tests for the Discord onboarding email command.""" + +from __future__ import annotations + +from unittest.mock import AsyncMock, Mock, patch + +import pytest + +from five08.clients.espo import EspoAPIError +from five08.discord_bot.cogs.onboarding_email import ( + OnboardingEmailCog, + OnboardingEmailCommandState, + OnboardingEmailContactSelectView, +) + + +@pytest.fixture +def mock_bot() -> Mock: + bot = Mock() + return bot + + +@pytest.fixture +def onboarding_cog(mock_bot: Mock) -> OnboardingEmailCog: + patcher = patch("five08.discord_bot.cogs.onboarding_email.settings") + mock_settings = patcher.start() + mock_settings.espo_api_key = "token" + mock_settings.espo_base_url = "https://crm.example.com" + mock_settings.audit_api_base_url = "https://audit.example.com" + mock_settings.api_shared_secret = "secret" + mock_settings.audit_api_timeout_seconds = 5.0 + mock_settings.discord_logs_webhook_url = None + mock_settings.discord_logs_webhook_wait = False + mock_settings.onboarding_email_sender_email = "onboarding@508.dev" + mock_settings.onboarding_email_smtp_server = "smtp.migadu.com" + mock_settings.onboarding_email_smtp_port = 465 + mock_settings.onboarding_email_smtp_use_ssl = True + mock_settings.onboarding_email_smtp_starttls = False + mock_settings.onboarding_email_smtp_username = "onboarding@508.dev" + mock_settings.onboarding_email_smtp_password = "secret" + mock_settings.onboarding_email_smtp_timeout_seconds = 20.0 + cog = OnboardingEmailCog(mock_bot) + cog.crm = Mock() + try: + yield cog + finally: + patcher.stop() + + +@pytest.fixture +def mock_interaction() -> AsyncMock: + interaction = AsyncMock() + interaction.response = AsyncMock() + interaction.response.defer = AsyncMock() + interaction.followup = AsyncMock() + interaction.followup.send = AsyncMock() + interaction.user = Mock() + interaction.user.id = 123 + interaction.user.name = "michaelmwu" + interaction.user.display_name = "Michael Wu" + admin_role = Mock() + admin_role.name = "Admin" + interaction.user.roles = [admin_role] + return interaction + + +def _contact_search_response(params: dict[str, object]) -> dict[str, object]: + select = str(params.get("select") or "") + if "cDiscordUserID" in select: + return { + "list": [ + { + "id": "contact-user", + "name": "Michael Wu", + "c508Email": "michael@508.dev", + "emailAddress": "michael@example.com", + "cDiscordUserID": "123", + "cDiscordUsername": "michaelmwu", + } + ] + } + if "cOnboarder" in select: + return { + "list": [ + { + "id": "contact-candidate", + "name": "Jane Example", + "emailAddress": "jane@example.com", + "c508Email": "", + "cOnboarder": "michael", + "cOnboardingState": "selected", + } + ] + } + return {"list": []} + + +@pytest.mark.asyncio +async def test_onboarding_email_command_generates_draft( + onboarding_cog: OnboardingEmailCog, + mock_interaction: AsyncMock, +) -> None: + onboarding_cog._audit_command_safe = Mock() + onboarding_cog.crm.list_contacts.side_effect = _contact_search_response + mock_interaction.user.display_name = "michaelmwu" + + await onboarding_cog.onboarding_email.callback( + onboarding_cog, + mock_interaction, + "Jane Example", + False, + discord_joined="no", + agreement_signed="unknown", + ) + + args, kwargs = mock_interaction.followup.send.call_args + assert "Onboarding email draft generated" in args[0] + assert "Reply-To: `michael@508.dev`" in args[0] + assert "From: `Michael Wu `" in args[0] + assert "CRM contact: `Jane Example`" in args[0] + assert "**Copy/paste draft:**" in args[0] + assert "Cheers,\nMichael" in args[0] + assert "[508 Discord server](https://discord.gg/9zAKxmUZJf)" in args[0] + assert kwargs["ephemeral"] is True + assert "files" not in kwargs + audit_kwargs = onboarding_cog._audit_command_safe.call_args.kwargs + assert audit_kwargs["result"] == "success" + assert audit_kwargs["metadata"]["email_action"] == "drafted" + assert audit_kwargs["metadata"]["sender_display_name"] == "Michael Wu" + assert audit_kwargs["metadata"]["signature_name"] == "Michael" + + +@pytest.mark.asyncio +async def test_onboarding_email_command_sends_when_requested( + onboarding_cog: OnboardingEmailCog, + mock_interaction: AsyncMock, +) -> None: + onboarding_cog._audit_command_safe = Mock() + onboarding_cog.crm.list_contacts.side_effect = _contact_search_response + onboarding_cog._send_message = Mock() + + await onboarding_cog.onboarding_email.callback( + onboarding_cog, + mock_interaction, + "Sam Member", + True, + recipient_email="sam@example.com", + discord_joined="yes", + agreement_signed="no", + sender_name="Michael Wu", + reply_to_email="michael@508.dev", + send=True, + ) + + onboarding_cog._send_message.assert_called_once() + message = onboarding_cog._send_message.call_args.args[0] + assert message["From"] == "Michael Wu " + assert message["Reply-To"] == "Michael Wu " + assert message["To"] == "sam@example.com" + assert message["Subject"] == "508.dev onboarding" + assert "Onboarding email sent" in mock_interaction.followup.send.call_args.args[0] + audit_kwargs = onboarding_cog._audit_command_safe.call_args.kwargs + assert audit_kwargs["result"] == "success" + assert audit_kwargs["metadata"]["email_action"] == "sent" + + +@pytest.mark.asyncio +async def test_onboarding_email_draft_does_not_require_crm_reply_to_lookup( + onboarding_cog: OnboardingEmailCog, + mock_interaction: AsyncMock, +) -> None: + onboarding_cog._audit_command_safe = Mock() + onboarding_cog.crm.list_contacts.side_effect = EspoAPIError("CRM unavailable") + + await onboarding_cog.onboarding_email.callback( + onboarding_cog, + mock_interaction, + "Jane Example", + False, + ) + + assert ( + "Onboarding email draft generated" + in mock_interaction.followup.send.call_args.args[0] + ) + assert ( + "Reply-To: `not resolved`" in mock_interaction.followup.send.call_args.args[0] + ) + + +@pytest.mark.asyncio +async def test_multiple_candidate_matches_show_selector( + onboarding_cog: OnboardingEmailCog, + mock_interaction: AsyncMock, +) -> None: + onboarding_cog._audit_command_safe = Mock() + onboarding_cog.crm.list_contacts.return_value = { + "list": [ + { + "id": "contact-1", + "name": "Jane Example", + "emailAddress": "jane@example.com", + "c508Email": "", + "cOnboarder": "michael", + "cOnboardingState": "selected", + }, + { + "id": "contact-2", + "name": "Jane Onboarded", + "emailAddress": "jane2@example.com", + "c508Email": "jane@508.dev", + "cOnboarder": "caleb", + "cOnboardingState": "onboarded", + }, + ] + } + + await onboarding_cog.onboarding_email.callback( + onboarding_cog, + mock_interaction, + "Jane", + False, + ) + + args, kwargs = mock_interaction.followup.send.call_args + assert "Multiple CRM contacts match" in args[0] + assert "Already-onboarded contacts" in args[0] + assert isinstance(kwargs["view"], OnboardingEmailContactSelectView) + assert kwargs["ephemeral"] is True + + +@pytest.mark.asyncio +async def test_multiple_candidate_matches_are_filtered_for_designated_onboarder( + onboarding_cog: OnboardingEmailCog, + mock_interaction: AsyncMock, +) -> None: + onboarding_cog._audit_command_safe = Mock() + member_role = Mock() + member_role.name = "Member" + mock_interaction.user.roles = [member_role] + + def list_contacts(params: dict[str, object]) -> dict[str, object]: + select = str(params.get("select") or "") + if "cDiscordUserID" in select: + return { + "list": [ + { + "id": "contact-user", + "name": "Michael Wu", + "c508Email": "michael@508.dev", + "cDiscordUserID": "123", + } + ] + } + if "cOnboarder" in select: + return { + "list": [ + { + "id": "contact-1", + "name": "Jane Assigned", + "emailAddress": "jane@example.com", + "cOnboarder": "michael", + "cOnboardingState": "selected", + }, + { + "id": "contact-2", + "name": "Jane Also Assigned", + "emailAddress": "jane2@example.com", + "cOnboarder": "michael@508.dev", + "cOnboardingState": "selected", + }, + { + "id": "contact-3", + "name": "Jane Other", + "emailAddress": "jane3@example.com", + "cOnboarder": "caleb", + "cOnboardingState": "selected", + }, + ] + } + return {"list": []} + + onboarding_cog.crm.list_contacts.side_effect = list_contacts + + await onboarding_cog.onboarding_email.callback( + onboarding_cog, + mock_interaction, + "Jane", + False, + ) + + args, kwargs = mock_interaction.followup.send.call_args + assert "Multiple CRM contacts match" in args[0] + view = kwargs["view"] + assert isinstance(view, OnboardingEmailContactSelectView) + select = view.children[0] + option_labels = [option.label for option in select.options] + assert option_labels == ["Jane Assigned", "Jane Also Assigned"] + + +@pytest.mark.asyncio +async def test_already_onboarded_candidate_is_reported_without_draft( + onboarding_cog: OnboardingEmailCog, + mock_interaction: AsyncMock, +) -> None: + onboarding_cog._audit_command_safe = Mock() + onboarding_cog.crm.list_contacts.return_value = { + "list": [ + { + "id": "contact-onboarded", + "name": "Jane Onboarded", + "emailAddress": "jane@example.com", + "c508Email": "jane@508.dev", + "cOnboarder": "michael", + "cOnboardingState": "onboarded", + } + ] + } + + await onboarding_cog.onboarding_email.callback( + onboarding_cog, + mock_interaction, + "Jane", + False, + ) + + args, kwargs = mock_interaction.followup.send.call_args + assert "already onboarded" in args[0] + assert "files" not in kwargs + metadata = onboarding_cog._audit_command_safe.call_args.kwargs["metadata"] + assert metadata["error"] == "candidate_already_onboarded" + + +@pytest.mark.asyncio +async def test_designated_onboarder_can_generate_candidate_email( + onboarding_cog: OnboardingEmailCog, + mock_interaction: AsyncMock, +) -> None: + onboarding_cog._audit_command_safe = Mock() + member_role = Mock() + member_role.name = "Member" + mock_interaction.user.roles = [member_role] + onboarding_cog.crm.list_contacts.side_effect = _contact_search_response + + await onboarding_cog.onboarding_email.callback( + onboarding_cog, + mock_interaction, + "Jane Example", + False, + recipient_email="jane@example.com", + ) + + assert ( + "Onboarding email draft generated" + in mock_interaction.followup.send.call_args.args[0] + ) + metadata = onboarding_cog._audit_command_safe.call_args.kwargs["metadata"] + assert metadata["authorization_source"] == "designated_onboarder" + + +@pytest.mark.asyncio +async def test_non_onboarder_member_is_denied( + onboarding_cog: OnboardingEmailCog, + mock_interaction: AsyncMock, +) -> None: + onboarding_cog._audit_command_safe = Mock() + member_role = Mock() + member_role.name = "Member" + mock_interaction.user.roles = [member_role] + + def list_contacts(params: dict[str, object]) -> dict[str, object]: + response = _contact_search_response(params) + select = str(params.get("select") or "") + if "cOnboarder" in select: + contact = response["list"][0] # type: ignore[index] + contact["cOnboarder"] = "caleb" # type: ignore[index] + return response + + onboarding_cog.crm.list_contacts.side_effect = list_contacts + + await onboarding_cog.onboarding_email.callback( + onboarding_cog, + mock_interaction, + "Jane Example", + False, + recipient_email="jane@example.com", + ) + + message = mock_interaction.followup.send.call_args.args[0] + assert "Only Steering Committee+ or the candidate's designated onboarder" in message + audit_kwargs = onboarding_cog._audit_command_safe.call_args.kwargs + assert audit_kwargs["result"] == "denied" + assert audit_kwargs["metadata"]["error"] == "permission_denied" + + +@pytest.mark.asyncio +async def test_designated_onboarder_authorization_requires_discord_user_id_link( + onboarding_cog: OnboardingEmailCog, + mock_interaction: AsyncMock, +) -> None: + onboarding_cog._audit_command_safe = Mock() + member_role = Mock() + member_role.name = "Member" + mock_interaction.user.roles = [member_role] + mock_interaction.user.id = 999 + mock_interaction.user.name = "michaelmwu" + mock_interaction.user.display_name = "Michael Wu" + + def list_contacts(params: dict[str, object]) -> dict[str, object]: + select = str(params.get("select") or "") + filters = str(params.get("where") or "") + if "cDiscordUserID" in select and "cDiscordUserID" in filters: + return {"list": []} + if "cDiscordUsername" in select and "cDiscordUsername" in filters: + return { + "list": [ + { + "id": "contact-user", + "name": "Michael Wu", + "c508Email": "michael@508.dev", + "cDiscordUsername": "michaelmwu", + } + ] + } + if "cOnboarder" in select: + return { + "list": [ + { + "id": "contact-candidate", + "name": "Jane Example", + "emailAddress": "jane@example.com", + "cOnboarder": "michael", + "cOnboardingState": "selected", + } + ] + } + return {"list": []} + + onboarding_cog.crm.list_contacts.side_effect = list_contacts + + await onboarding_cog.onboarding_email.callback( + onboarding_cog, + mock_interaction, + "Jane Example", + False, + ) + + message = mock_interaction.followup.send.call_args.args[0] + assert "Only Steering Committee+ or the candidate's designated onboarder" in message + audit_kwargs = onboarding_cog._audit_command_safe.call_args.kwargs + assert audit_kwargs["result"] == "denied" + assert audit_kwargs["metadata"]["error"] == "permission_denied" + + +@pytest.mark.asyncio +async def test_unauthorized_selection_authorizes_before_draft_and_reply_to_lookup( + onboarding_cog: OnboardingEmailCog, + mock_interaction: AsyncMock, +) -> None: + onboarding_cog._audit_command_safe = Mock() + member_role = Mock() + member_role.name = "Member" + mock_interaction.user.roles = [member_role] + state = OnboardingEmailCommandState( + candidate_name="Jane Example", + has_contributed=False, + recipient_email="jane@example.com", + discord_joined="unknown", + agreement_signed="unknown", + sender_display_name="Michael Wu", + signature_name="Michael", + reply_to_email=None, + send=False, + ) + selected_contact = { + "id": "contact-candidate", + "name": "Jane Example", + "emailAddress": "jane@example.com", + "cOnboarder": "caleb", + "cOnboardingState": "selected", + } + onboarding_cog.crm.list_contacts.return_value = { + "list": [ + { + "id": "contact-user", + "name": "Michael Wu", + "c508Email": "michael@508.dev", + "cDiscordUserID": "123", + } + ] + } + + with ( + patch( + "five08.discord_bot.cogs.onboarding_email.build_onboarding_email" + ) as build_mock, + patch.object(onboarding_cog, "_reply_to_email_for_user") as reply_to_mock, + ): + await onboarding_cog._run_onboarding_email_flow( + mock_interaction, + state=state, + selected_contact=selected_contact, + ) + + build_mock.assert_not_called() + reply_to_mock.assert_not_called() + audit_kwargs = onboarding_cog._audit_command_safe.call_args.kwargs + assert audit_kwargs["result"] == "denied" + assert audit_kwargs["metadata"]["error"] == "permission_denied" + + +@pytest.mark.asyncio +async def test_onboarding_email_send_requires_reply_to( + onboarding_cog: OnboardingEmailCog, + mock_interaction: AsyncMock, +) -> None: + onboarding_cog._audit_command_safe = Mock() + + def list_contacts(params: dict[str, object]) -> dict[str, object]: + select = str(params.get("select") or "") + if "cOnboarder" in select: + return { + "list": [ + { + "id": "contact-candidate", + "name": "Sam Member", + "emailAddress": "sam@example.com", + "c508Email": "", + "cOnboarder": "michael", + "cOnboardingState": "selected", + } + ] + } + return {"list": []} + + onboarding_cog.crm.list_contacts.side_effect = list_contacts + onboarding_cog._send_message = Mock() + + await onboarding_cog.onboarding_email.callback( + onboarding_cog, + mock_interaction, + "Sam Member", + True, + recipient_email="sam@example.com", + send=True, + ) + + onboarding_cog._send_message.assert_not_called() + assert ( + "reply_to_email is required" in mock_interaction.followup.send.call_args.args[0] + ) + audit_kwargs = onboarding_cog._audit_command_safe.call_args.kwargs + assert audit_kwargs["result"] == "error" + assert audit_kwargs["metadata"]["error"] == "validation_error" + + +@pytest.mark.asyncio +async def test_sender_identity_falls_back_to_crm_discord_username( + onboarding_cog: OnboardingEmailCog, + mock_interaction: AsyncMock, +) -> None: + onboarding_cog._audit_command_safe = Mock() + + def list_contacts(params: dict[str, object]) -> dict[str, object]: + select = str(params.get("select") or "") + filters = str(params.get("where") or "") + if "cDiscordUserID" in select and "cDiscordUserID" in filters: + return {"list": []} + if "cDiscordUsername" in select and "cDiscordUsername" in filters: + return { + "list": [ + { + "id": "contact-user", + "name": "Michael Wu", + "c508Email": "michael@508.dev", + "cDiscordUsername": "michaelmwu", + } + ] + } + if "cOnboarder" in select: + return { + "list": [ + { + "id": "contact-candidate", + "name": "Jane Example", + "emailAddress": "jane@example.com", + "cOnboarder": "michael", + "cOnboardingState": "selected", + } + ] + } + return {"list": []} + + onboarding_cog.crm.list_contacts.side_effect = list_contacts + + await onboarding_cog.onboarding_email.callback( + onboarding_cog, + mock_interaction, + "Jane Example", + False, + ) + + message = mock_interaction.followup.send.call_args.args[0] + assert "From: `Michael Wu `" in message + assert "Cheers,\nMichael" in message + + +@pytest.mark.asyncio +async def test_onboarding_email_flow_sanitizes_crm_errors( + onboarding_cog: OnboardingEmailCog, + mock_interaction: AsyncMock, +) -> None: + onboarding_cog._audit_command_safe = Mock() + state = OnboardingEmailCommandState( + candidate_name="Jane Example", + has_contributed=False, + recipient_email="jane@example.com", + discord_joined="unknown", + agreement_signed="unknown", + sender_display_name="Michael Wu", + signature_name="Michael", + reply_to_email=None, + send=False, + ) + onboarding_cog._complete_onboarding_email = AsyncMock( + side_effect=EspoAPIError("token leaked raw CRM detail") + ) + + await onboarding_cog._run_onboarding_email_flow( + mock_interaction, + state=state, + selected_contact=None, + ) + + message = mock_interaction.followup.send.call_args.args[0] + assert "CRM lookup failed" in message + assert "token leaked" not in message + audit_kwargs = onboarding_cog._audit_command_safe.call_args.kwargs + assert audit_kwargs["result"] == "error" + assert audit_kwargs["metadata"]["error"] == "crm_lookup_failed" + + +@pytest.mark.asyncio +async def test_send_draft_response_chunks_only_markdown_body( + onboarding_cog: OnboardingEmailCog, + mock_interaction: AsyncMock, +) -> None: + await onboarding_cog._send_draft_response( + mock_interaction, + summary="S" * 1800, + markdown_body="M" * 4000, + ) + + calls = mock_interaction.followup.send.call_args_list + assert calls[0].args[0] == "S" * 1800 + assert calls[1].args[0].startswith("**Copy/paste draft (1/3):**\nM") + assert "**Copy/paste draft:**\n" not in calls[1].args[0] + + +@pytest.mark.asyncio +async def test_send_draft_response_chunks_oversized_summary( + onboarding_cog: OnboardingEmailCog, + mock_interaction: AsyncMock, +) -> None: + await onboarding_cog._send_draft_response( + mock_interaction, + summary="S" * 4100, + markdown_body="Short draft", + ) + + calls = mock_interaction.followup.send.call_args_list + assert calls[0].args[0].startswith("**Summary (1/3):**\nS") + assert calls[1].args[0].startswith("**Summary (2/3):**\nS") + assert calls[2].args[0].startswith("**Summary (3/3):**\nS") + assert calls[3].args[0] == "**Copy/paste draft:**\nShort draft" + + +def test_send_message_requires_tls( + onboarding_cog: OnboardingEmailCog, +) -> None: + message = Mock() + + with patch("five08.discord_bot.cogs.onboarding_email.settings") as mock_settings: + mock_settings.onboarding_email_smtp_server = "smtp.migadu.com" + mock_settings.onboarding_email_smtp_username = "onboarding@508.dev" + mock_settings.onboarding_email_smtp_password = "secret" + mock_settings.onboarding_email_smtp_use_ssl = False + mock_settings.onboarding_email_smtp_starttls = False + + with pytest.raises(ValueError, match="SMTP requires TLS"): + onboarding_cog._send_message(message) + + +def test_send_message_uses_ssl_context( + onboarding_cog: OnboardingEmailCog, +) -> None: + message = Mock() + tls_context = Mock() + + with ( + patch( + "five08.discord_bot.cogs.onboarding_email.ssl.create_default_context", + return_value=tls_context, + ), + patch("five08.discord_bot.cogs.onboarding_email.smtplib.SMTP_SSL") as smtp_ssl, + ): + smtp = smtp_ssl.return_value.__enter__.return_value + + onboarding_cog._send_message(message) + + smtp_ssl.assert_called_once_with( + "smtp.migadu.com", + 465, + timeout=20.0, + context=tls_context, + ) + smtp.login.assert_called_once_with("onboarding@508.dev", "secret") + smtp.send_message.assert_called_once_with(message)