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 }