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' + "Great Talking,
Jane
Cheers,
Michael Wu