Source identity
| Field | Recorded value |
|---|---|
| Source type | GitHub source |
| Original record | Janady13/missionmatch / internal/modules/recruiting/messaging.go |
| Date | Not specified in source |
| Repository | Janady13/missionmatch |
| Revision | 720d47fae476b0d38477ae4401697d372b1ea2bc |
| Branch | feature/opportunity-to-outcome |
| Path or archive member | internal/modules/recruiting/messaging.go |
| Original SHA-256 | 8756a8602b751042f87e5e79d05a099af0a5030a7237170093cdcd1def7f9485 |
| Git blob SHA | 608087e13257399b91616cd025a8748df22f7ee7 |
| Reading-copy SHA-256 | 8756a8602b751042f87e5e79d05a099af0a5030a7237170093cdcd1def7f9485 |
Recovered source transcript
Download complete source text ↓471 lines · 16.6 KB
package recruiting
// OUTBOUND CORRESPONDENCE. Writing to a candidate or a client contact from their record.
//
// This did not exist. The only two send calls in the whole codebase were the account invite and the settings
// test message, so a product built for people whose day is correspondence had no way to write any. A route
// inventory against the established products in this market put it first among the gaps.
//
// IT SENDS THROUGH THE ORGANIZATION'S OWN MAIL SERVER, the one configured in migration 0087, so a candidate
// hears from talent@theiragency.com rather than a domain they were trained to distrust, and it passes their
// own SPF and DKIM because it genuinely is them.
//
// EVERY SEND WRITES TWO ROWS: the message itself, retained in full, and an ACTIVITY, so a colleague reading
// the candidate's history sees the outreach in the same stream as the calls and the meetings rather than in
// a separate place they have to remember to look.
//
// PLACEHOLDERS ARE RESOLVED SERVER SIDE from the record. A template that let the client fill in the name is a
// template that will one day address somebody as the wrong person, which is the single most expensive
// mistake in outreach and the one that ends a candidate relationship instantly.
import (
"context"
"encoding/json"
"errors"
"fmt"
"strings"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/missionworkforce/missionmatch/internal/platform/audit"
"github.com/missionworkforce/missionmatch/internal/platform/mailer"
"github.com/missionworkforce/missionmatch/internal/platform/orgsettings"
"github.com/missionworkforce/missionmatch/internal/platform/tenant"
)
var (
ErrMailNotConfigured = errors.New("recruiting: this organization has not configured and verified outbound mail")
ErrNoRecipient = errors.New("recruiting: that record has no email address")
ErrRecipientUnknown = errors.New("recruiting: no such recipient in this organization")
ErrMessageEmpty = errors.New("recruiting: a message needs a subject and a body")
ErrTemplateUnknown = errors.New("recruiting: no such template")
)
// MessageInput is one outbound message. Either CandidateID or ContactID is set, never both.
type MessageInput struct {
CandidateID string `json:"candidate_id"`
ContactID string `json:"contact_id"`
RequisitionID string `json:"requisition_id"`
ApplicationID string `json:"application_id"`
TemplateID string `json:"template_id"`
Subject string `json:"subject"`
Body string `json:"body"`
}
// Message is a sent record.
type Message struct {
ID string `json:"id"`
CandidateID string `json:"candidate_id,omitempty"`
ContactID string `json:"contact_id,omitempty"`
ToAddress string `json:"to_address"`
FromAddress string `json:"from_address"`
Subject string `json:"subject"`
Body string `json:"body"`
Status string `json:"status"`
ErrorText string `json:"error_text,omitempty"`
SentByName string `json:"sent_by_name,omitempty"`
CreatedAt time.Time `json:"created_at"`
SentAt *time.Time `json:"sent_at,omitempty"`
}
// recipient is what a send needs to know about the person on the other end.
type recipient struct {
kind string // candidate or contact
id uuid.UUID
name string
email string
}
// WithMessaging injects the settings store used to resolve the organization's mail configuration. Nil means
// the messaging routes report themselves unavailable rather than silently doing nothing.
func (s *Service) WithMessaging(store *orgsettings.Store) *Service {
s.settings = store
return s
}
// SendMessage writes to one person and records what was written.
//
// THE MESSAGE ROW IS WRITTEN BEFORE THE SEND, in status queued, and updated after. Writing it afterwards
// means a send that succeeds while the database is briefly unavailable leaves a candidate holding a message
// the agency has no record of, which is the failure that makes a compliance trail worthless.
func (s *Service) SendMessage(ctx context.Context, in MessageInput, ip, ua string) (Message, error) {
var out Message
if s.db == nil {
return out, ErrNoDatabase
}
if s.settings == nil {
return out, ErrMailNotConfigured
}
actor := actorPtr(ctx)
if actor == nil {
return out, tenant.ErrNoTenant
}
var cfg mailer.Config
var rcpt recipient
var msgID uuid.UUID
subject, body := strings.TrimSpace(in.Subject), in.Body
err := s.db.RunInTenantTx(ctx, func(ctx context.Context, tx pgx.Tx) error {
st, e := s.settings.LoadTx(ctx, tx)
if e != nil {
return e
}
// THREE CONDITIONS, ALL REQUIRED, the same rule invite delivery follows: switched on, proven by a
// real test send, and actually configured.
if !st.EmailDeliveryEnabled || st.SMTPVerifiedAt == nil ||
st.SMTPHost == "" || st.SMTPPort == 0 || st.EmailFromAddress == "" {
return ErrMailNotConfigured
}
rcpt, e = resolveRecipient(ctx, tx, in, true)
if e != nil {
return e
}
// A template is resolved and expanded HERE, inside the transaction, against the record we just read.
if strings.TrimSpace(in.TemplateID) != "" {
tid, perr := uuid.Parse(strings.TrimSpace(in.TemplateID))
if perr != nil {
return ErrTemplateUnknown
}
var ts, tb string
e = tx.QueryRow(ctx, `SELECT subject, body FROM message_templates WHERE id=$1 AND status='active'`, tid).
Scan(&ts, &tb)
if errors.Is(e, pgx.ErrNoRows) {
return ErrTemplateUnknown
}
if e != nil {
return e
}
// An explicit subject or body on the request WINS over the template, because a recruiter who
// edited the draft before sending meant it.
if subject == "" {
subject = ts
}
if strings.TrimSpace(body) == "" {
body = tb
}
}
subject = ExpandPlaceholders(subject, rcpt, st.EmailFromName)
body = ExpandPlaceholders(body, rcpt, st.EmailFromName)
if subject == "" || strings.TrimSpace(body) == "" {
return ErrMessageEmpty
}
pw, e := s.settings.OpenSecret(ctx, tx, orgsettings.SecretSMTPPassword)
if e != nil && !errors.Is(e, orgsettings.ErrNoSecret) {
return e
}
cfg = mailer.Config{
Host: st.SMTPHost, Port: st.SMTPPort, Username: st.SMTPUsername, Password: pw,
Security: st.SMTPSecurity, From: st.EmailFromAddress, FromName: st.EmailFromName,
ReplyTo: st.EmailReplyTo,
}
return tx.QueryRow(ctx, `
INSERT INTO messages (organization_id, candidate_id, contact_id, requisition_id, application_id,
to_address, from_address, subject, body, template_id, sent_by_user_id, status)
VALUES (NULLIF(current_setting('app.current_org_id', true), '')::uuid,
$1,$2,$3,$4,$5,$6,$7,$8,$9,$10,'queued')
RETURNING id`,
nullIf(rcpt.kind == "candidate", rcpt.id), nullIf(rcpt.kind == "contact", rcpt.id),
nullUUIDText(in.RequisitionID), nullUUIDText(in.ApplicationID),
rcpt.email, cfg.From, subject, body, nullUUIDText(in.TemplateID), *actor,
).Scan(&msgID)
})
if err != nil {
return out, err
}
sendCtx, cancel := context.WithTimeout(ctx, 40*time.Second)
defer cancel()
sendErr := mailer.Send(sendCtx, cfg, mailer.Message{To: rcpt.email, Subject: subject, Body: body})
// The outcome is recorded in its own transaction so a failed send still leaves a row a recruiter can see
// and retry from, rather than vanishing.
_ = s.db.RunInTenantTx(ctx, func(ctx context.Context, tx pgx.Tx) error {
if sendErr != nil {
msg := scrub(sendErr.Error(), cfg.Username, cfg.Password)
if len(msg) > 500 {
msg = msg[:500]
}
_, e := tx.Exec(ctx, `UPDATE messages SET status='failed', error_text=$2 WHERE id=$1`, msgID, msg)
return e
}
if _, e := tx.Exec(ctx, `UPDATE messages SET status='sent', sent_at=now() WHERE id=$1`, msgID); e != nil {
return e
}
// THE ACTIVITY ROW. This is what puts the outreach in the same history as the calls and meetings, so
// a colleague picking the record up tomorrow reads one stream and not two.
//
// auto_captured IS TRUE, and that is the honest value. The flag distinguishes effort the platform
// recorded itself from effort a human typed into a log, and mixing them makes any productivity
// comparison meaningless. A recruiter wrote this message, but they did not write the activity entry.
//
// The subject is carried INTO the body because recruiter_activities has no subject column, and
// inventing one here to suit messaging would change a table five other surfaces already read.
if _, e := tx.Exec(ctx, `
INSERT INTO recruiter_activities
(organization_id, actor_user_id, kind, direction, outcome,
candidate_id, contact_id, requisition_id, application_id, body, auto_captured)
VALUES (NULLIF(current_setting('app.current_org_id', true), '')::uuid,
$1, 'email', 'outbound', 'logged', $2, $3, $4, $5, $6, true)`,
*actor,
nullIf(rcpt.kind == "candidate", rcpt.id), nullIf(rcpt.kind == "contact", rcpt.id),
nullUUIDText(in.RequisitionID), nullUUIDText(in.ApplicationID),
subject+"\n\n"+body); e != nil {
return e
}
if s.audit != nil {
b, _ := json.Marshal(map[string]any{
"message_id": msgID.String(), "to": rcpt.email, "recipient_kind": rcpt.kind,
"subject": subject,
})
return s.audit.OnTx(ctx, tx, audit.Event{
Action: "message.sent", ActorUserID: actor, EntityType: rcpt.kind,
EntityID: rcpt.id.String(), NewValues: b, IPAddress: ip, UserAgent: ua,
})
}
return nil
})
out = Message{
ID: msgID.String(), ToAddress: rcpt.email, FromAddress: cfg.From,
Subject: subject, Body: body, Status: "sent", CreatedAt: time.Now().UTC(),
}
if rcpt.kind == "candidate" {
out.CandidateID = rcpt.id.String()
} else {
out.ContactID = rcpt.id.String()
}
if sendErr != nil {
out.Status = "failed"
out.ErrorText = scrub(sendErr.Error(), cfg.Username, cfg.Password)
// The message row exists either way, so this is reported as a failed send rather than as a lost one.
return out, fmt.Errorf("recruiting: the mail server refused the message: %s", out.ErrorText)
}
return out, nil
}
// ListMessages returns correspondence for one record, newest first.
func (s *Service) ListMessages(ctx context.Context, kind string, id uuid.UUID) ([]Message, error) {
if s.db == nil {
return nil, ErrNoDatabase
}
col := "candidate_id"
if kind == "contact" {
col = "contact_id"
}
out := make([]Message, 0, 32)
err := s.db.RunInTenantTx(ctx, func(ctx context.Context, tx pgx.Tx) error {
rows, e := tx.Query(ctx, `
SELECT m.id::text, coalesce(m.candidate_id::text,''), coalesce(m.contact_id::text,''),
m.to_address, m.from_address, m.subject, m.body, m.status, m.error_text,
coalesce(u.full_name,''), m.created_at, m.sent_at
FROM messages m LEFT JOIN users u ON u.id = m.sent_by_user_id
WHERE m.`+col+` = $1
ORDER BY m.created_at DESC LIMIT 200`, id)
if e != nil {
return e
}
defer rows.Close()
for rows.Next() {
var m Message
if e := rows.Scan(&m.ID, &m.CandidateID, &m.ContactID, &m.ToAddress, &m.FromAddress,
&m.Subject, &m.Body, &m.Status, &m.ErrorText, &m.SentByName, &m.CreatedAt, &m.SentAt); e != nil {
return e
}
out = append(out, m)
}
return rows.Err()
})
return out, err
}
// resolveRecipient looks up the person a message is for.
//
// requireEmail is FALSE on the preview path and true on the send path, and the difference is the point.
// Previewing what a template says is useful whether or not the record has an address yet, and refusing to
// render the text because a field is blank sends a recruiter looking for a bug that is not there. Sending
// without an address is impossible, so that path still insists.
func resolveRecipient(ctx context.Context, tx pgx.Tx, in MessageInput, requireEmail bool) (recipient, error) {
cand := strings.TrimSpace(in.CandidateID)
cont := strings.TrimSpace(in.ContactID)
if (cand == "") == (cont == "") {
return recipient{}, errors.New("recruiting: name exactly one recipient, a candidate or a contact")
}
var r recipient
var q string
var raw string
if cand != "" {
r.kind, raw = "candidate", cand
q = `SELECT full_name, coalesce(email,'') FROM candidates WHERE id=$1 AND deleted_at IS NULL`
} else {
r.kind, raw = "contact", cont
q = `SELECT coalesce(full_name,''), coalesce(email,'') FROM client_contacts WHERE id=$1`
}
id, err := uuid.Parse(raw)
if err != nil {
return recipient{}, ErrRecipientUnknown
}
r.id = id
if e := tx.QueryRow(ctx, q, id).Scan(&r.name, &r.email); e != nil {
if errors.Is(e, pgx.ErrNoRows) {
return recipient{}, ErrRecipientUnknown
}
return recipient{}, e
}
if requireEmail && strings.TrimSpace(r.email) == "" {
return recipient{}, ErrNoRecipient
}
return r, nil
}
// ExpandPlaceholders resolves the small closed set of tokens a template may use, FROM THE RECORD. It is
// exported so the interface can preview exactly what will be sent before anybody presses send.
//
// The set is deliberately tiny. Every token here is something the server can always answer correctly; a
// larger set would mean templates that silently render an empty greeting when a field happens to be blank,
// and "Hi ," is worse than no personalisation at all, which is why first_name falls back to the full name and
// then to a neutral word rather than to nothing.
func ExpandPlaceholders(s string, r recipient, fromName string) string {
first := firstName(r.name)
rep := strings.NewReplacer(
"{{first_name}}", first,
"{{full_name}}", orDefault(r.name, first),
"{{email}}", r.email,
"{{sender_name}}", fromName,
)
return rep.Replace(s)
}
func firstName(full string) string {
f := strings.Fields(strings.TrimSpace(full))
if len(f) == 0 {
return "there"
}
return f[0]
}
func orDefault(v, d string) string {
if strings.TrimSpace(v) == "" {
return d
}
return v
}
func scrub(s, username, password string) string {
for _, secret := range []string{password, username} {
if len(secret) >= 4 {
s = strings.ReplaceAll(s, secret, "[redacted]")
}
}
return s
}
func nullIf(cond bool, id uuid.UUID) any {
if cond {
return id
}
return nil
}
func nullUUIDText(s string) any {
s = strings.TrimSpace(s)
if s == "" {
return nil
}
id, err := uuid.Parse(s)
if err != nil {
return nil
}
return id
}
// Placeholders is the closed set a template may use. It is returned WITH the template list so the interface
// renders the real vocabulary rather than a hard coded copy that drifts from this one.
func Placeholders() []string {
return []string{"{{first_name}}", "{{full_name}}", "{{email}}", "{{sender_name}}"}
}
// unknownPlaceholders returns the first token the system cannot fill, or the empty string when every token is
// known. Refusing at authoring time is what stops a candidate receiving a visible curly brace.
func unknownPlaceholders(s string) string {
known := map[string]bool{}
for _, p := range Placeholders() {
known[p] = true
}
for {
i := strings.Index(s, "{{")
if i < 0 {
return ""
}
j := strings.Index(s[i:], "}}")
if j < 0 {
return s[i:min(i+24, len(s))]
}
tok := s[i : i+j+2]
if !known[tok] {
return tok
}
s = s[i+j+2:]
}
}
func min(a, b int) int {
if a < b {
return a
}
return b
}
// PreviewMessage resolves a template against a real record and returns exactly what would be sent, WITHOUT
// sending it and without writing a message row. It deliberately shares resolveRecipient and
// ExpandPlaceholders with the send path, so a preview that looks right cannot be followed by a send that
// does not: two separate implementations would drift on the first change to either.
func (s *Service) PreviewMessage(ctx context.Context, in MessageInput) (subject, body, to string, err error) {
if s.db == nil {
return "", "", "", ErrNoDatabase
}
subject, body = strings.TrimSpace(in.Subject), in.Body
err = s.db.RunInTenantTx(ctx, func(ctx context.Context, tx pgx.Tx) error {
rcpt, e := resolveRecipient(ctx, tx, in, false)
if e != nil {
return e
}
to = rcpt.email
if strings.TrimSpace(in.TemplateID) != "" {
tid, perr := uuid.Parse(strings.TrimSpace(in.TemplateID))
if perr != nil {
return ErrTemplateUnknown
}
var ts, tb string
e = tx.QueryRow(ctx, `SELECT subject, body FROM message_templates WHERE id=$1 AND status='active'`, tid).
Scan(&ts, &tb)
if errors.Is(e, pgx.ErrNoRows) {
return ErrTemplateUnknown
}
if e != nil {
return e
}
if subject == "" {
subject = ts
}
if strings.TrimSpace(body) == "" {
body = tb
}
}
// The sender name comes from settings when they are available, so the preview matches the send. A
// node without a settings store still previews, with an empty sender name, rather than refusing.
fromName := ""
if s.settings != nil {
if st, se := s.settings.LoadTx(ctx, tx); se == nil {
fromName = st.EmailFromName
}
}
subject = ExpandPlaceholders(subject, rcpt, fromName)
body = ExpandPlaceholders(body, rcpt, fromName)
return nil
})
return subject, body, to, err
}