package recruiting import ( "context" "encoding/json" "errors" "net/http" "strings" "github.com/go-chi/chi/v5" "github.com/google/uuid" "github.com/jackc/pgx/v5" "github.com/missionworkforce/missionmatch/internal/platform/security" ) // MessagingRoutes serves correspondence and the templates behind it. // // SENDING IS candidate.write, NOT a management capability. Writing to a candidate you are working is ordinary // recruiting, and a recruiter who cannot send is a recruiter who cannot do the job. Reading correspondence is // candidate.read, because a colleague picking up the record needs to know what has already been said before // they say it again. // // TEMPLATE WRITES ARE org.settings.write. A template goes out under the organization's name to people who are // not its employees, so who may author one is the same authority that configures the organization. func (h *Handler) MessagingRoutes(r chi.Router) { read := security.RequireCapability(security.CapCandidateRead) write := security.RequireCapability(security.CapCandidateWrite) admin := security.RequireCapability(security.CapOrgSettings) r.With(write).Post("/messages", h.sendMessage) r.With(read).Get("/candidates/{candidateID}/messages", h.candidateMessages) r.With(read).Get("/contacts/{contactID}/messages", h.contactMessages) r.With(read).Get("/message-templates", h.listTemplates) r.With(admin).Post("/message-templates", h.createTemplate) r.With(admin).Patch("/message-templates/{templateID}", h.updateTemplate) // The preview resolves placeholders against the REAL record and returns exactly what would be sent. It is // a read, gated at the read tier, and it exists so nobody ever discovers a broken greeting by sending it. r.With(read).Post("/message-templates/preview", h.previewTemplate) } func (h *Handler) sendMessage(w http.ResponseWriter, r *http.Request) { var in MessageInput if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 64<<10)).Decode(&in); err != nil { writeErr(w, http.StatusBadRequest, "invalid body", "") return } msg, err := h.svc.SendMessage(r.Context(), in, r.Header.Get("X-Forwarded-For"), r.UserAgent()) if err != nil { h.failMessage(w, err, msg) return } writeJSON(w, http.StatusCreated, msg) } func (h *Handler) candidateMessages(w http.ResponseWriter, r *http.Request) { h.messagesFor(w, r, "candidate", "candidateID") } func (h *Handler) contactMessages(w http.ResponseWriter, r *http.Request) { h.messagesFor(w, r, "contact", "contactID") } func (h *Handler) messagesFor(w http.ResponseWriter, r *http.Request, kind, param string) { id, err := uuid.Parse(chi.URLParam(r, param)) if err != nil { writeErr(w, http.StatusBadRequest, "that is not an identifier", "") return } msgs, err := h.svc.ListMessages(r.Context(), kind, id) if err != nil { writeErr(w, http.StatusInternalServerError, "could not read correspondence", "") return } writeJSON(w, http.StatusOK, map[string]any{"messages": msgs, "count": len(msgs)}) } func (h *Handler) failMessage(w http.ResponseWriter, err error, msg Message) { switch { case errors.Is(err, ErrMailNotConfigured): // 409 rather than 500: nothing is broken, the organization simply has not finished setting mail up, // and the message says which screen fixes it. writeErr(w, http.StatusConflict, "outbound mail is not set up for this organization yet, configure it in settings and send a test message", "") case errors.Is(err, ErrRecipientUnknown): writeErr(w, http.StatusNotFound, "no such recipient", "") case errors.Is(err, ErrNoRecipient): writeErr(w, http.StatusUnprocessableEntity, "that record has no email address", "") case errors.Is(err, ErrMessageEmpty): writeErr(w, http.StatusUnprocessableEntity, "a message needs a subject and a body", "") case errors.Is(err, ErrTemplateUnknown): writeErr(w, http.StatusNotFound, "no such template", "") case msg.Status == "failed": // THE MESSAGE ROW EXISTS. This is reported as a delivery failure with the transport's own scrubbed // words, not as a lost message, so a recruiter knows the attempt is on the record and can retry. writeJSON(w, http.StatusBadGateway, map[string]any{ "error": err.Error(), "message_id": msg.ID, "status": "failed", "note": "the message is recorded against the candidate and can be retried", }) default: writeErr(w, http.StatusInternalServerError, "could not send the message", "") } } type templateRequest struct { Name string `json:"name"` Audience string `json:"audience"` Subject string `json:"subject"` Body string `json:"body"` Status *string `json:"status"` } type templateView struct { ID string `json:"id"` Name string `json:"name"` Audience string `json:"audience"` Subject string `json:"subject"` Body string `json:"body"` Status string `json:"status"` } func (h *Handler) listTemplates(w http.ResponseWriter, r *http.Request) { out := make([]templateView, 0, 16) err := h.svc.db.RunInTenantTx(r.Context(), func(ctx context.Context, tx pgx.Tx) error { rows, e := tx.Query(ctx, `SELECT id::text, name, audience, subject, body, status FROM message_templates ORDER BY status, lower(name)`) if e != nil { return e } defer rows.Close() for rows.Next() { var t templateView if e := rows.Scan(&t.ID, &t.Name, &t.Audience, &t.Subject, &t.Body, &t.Status); e != nil { return e } out = append(out, t) } return rows.Err() }) if err != nil { writeErr(w, http.StatusInternalServerError, "could not read templates", "") return } writeJSON(w, http.StatusOK, map[string]any{ "templates": out, "count": len(out), // The vocabulary is returned WITH the list so the interface never has to hard code it and drift. "placeholders": Placeholders(), }) } func (h *Handler) createTemplate(w http.ResponseWriter, r *http.Request) { var in templateRequest if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 64<<10)).Decode(&in); err != nil { writeErr(w, http.StatusBadRequest, "invalid body", "") return } if field, err := validTemplate(in); err != nil { writeJSON(w, http.StatusUnprocessableEntity, map[string]string{"error": err.Error(), "field": field}) return } actor := actorPtr(r.Context()) var id string err := h.svc.db.RunInTenantTx(r.Context(), func(ctx context.Context, tx pgx.Tx) error { return tx.QueryRow(ctx, ` INSERT INTO message_templates (organization_id, name, audience, subject, body, created_by) VALUES (NULLIF(current_setting('app.current_org_id', true), '')::uuid, $1,$2,$3,$4,$5) RETURNING id::text`, strings.TrimSpace(in.Name), in.Audience, in.Subject, in.Body, actor).Scan(&id) }) if err != nil { if strings.Contains(err.Error(), "duplicate key") { writeErr(w, http.StatusConflict, "a template with that name already exists", "") return } writeErr(w, http.StatusInternalServerError, "could not create the template", "") return } writeJSON(w, http.StatusCreated, map[string]any{"id": id}) } func (h *Handler) updateTemplate(w http.ResponseWriter, r *http.Request) { id, err := uuid.Parse(chi.URLParam(r, "templateID")) if err != nil { writeErr(w, http.StatusBadRequest, "that is not an identifier", "") return } var in templateRequest if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 64<<10)).Decode(&in); err != nil { writeErr(w, http.StatusBadRequest, "invalid body", "") return } if in.Status != nil && *in.Status != "active" && *in.Status != "archived" { writeJSON(w, http.StatusUnprocessableEntity, map[string]string{"error": "a template is either active or archived", "field": "status"}) return } err = h.svc.db.RunInTenantTx(r.Context(), func(ctx context.Context, tx pgx.Tx) error { tag, e := tx.Exec(ctx, ` UPDATE message_templates SET name = coalesce(nullif(btrim($2),''), name), subject = coalesce($3, subject), body = coalesce($4, body), status = coalesce($5, status), updated_at = now() WHERE id = $1`, id, in.Name, nullIfEmptyStr(in.Subject), nullIfEmptyStr(in.Body), in.Status) if e != nil { return e } if tag.RowsAffected() == 0 { return ErrTemplateUnknown } return nil }) if err != nil { if errors.Is(err, ErrTemplateUnknown) { writeErr(w, http.StatusNotFound, "no such template", "") return } writeErr(w, http.StatusInternalServerError, "could not update the template", "") return } writeJSON(w, http.StatusOK, map[string]any{"updated": true}) } // previewTemplate resolves a template against a real record and returns exactly what would be sent, without // sending it. This is the difference between a template feature people trust and one they test by mailing // themselves. func (h *Handler) previewTemplate(w http.ResponseWriter, r *http.Request) { var in MessageInput if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 64<<10)).Decode(&in); err != nil { writeErr(w, http.StatusBadRequest, "invalid body", "") return } subject, body, to, err := h.svc.PreviewMessage(r.Context(), in) if err != nil { h.failMessage(w, err, Message{}) return } resp := map[string]any{ "to_address": to, "subject": subject, "body": body, "sendable": to != "", "note": "this is exactly what would be sent, nothing has been sent", } if to == "" { // The text still renders, and the reason it cannot go out is stated plainly. Refusing the whole // preview because a field is blank would send a recruiter hunting a bug that is not there. resp["note"] = "this record has no email address, so the message can be previewed but not sent" } writeJSON(w, http.StatusOK, resp) } func validTemplate(in templateRequest) (string, error) { if n := strings.TrimSpace(in.Name); n == "" || len(n) > 120 { return "name", errors.New("a template needs a name of up to 120 characters") } if in.Audience != "candidate" && in.Audience != "contact" { return "audience", errors.New("a template is written for a candidate or a contact") } if len(in.Subject) > 300 { return "subject", errors.New("a subject may be up to 300 characters") } if strings.TrimSpace(in.Body) == "" || len(in.Body) > 20000 { return "body", errors.New("a template needs a body of up to 20000 characters") } if bad := unknownPlaceholders(in.Subject + " " + in.Body); bad != "" { // REFUSED RATHER THAN LEFT TO RENDER LITERALLY. A template carrying an unknown token sends a message // with a visible curly brace to a candidate, which reads as a broken mail merge and costs the // relationship. return "body", errors.New("this template uses a placeholder this system cannot fill: " + bad) } return "", nil } func nullIfEmptyStr(s string) any { if s == "" { return nil } return s } // MailSyncRoutes are the caller's OWN mailbox. Every route here acts on the signed in user and takes no user // parameter, deliberately: a mailbox belongs to a person, and an endpoint that let one person configure // another's would be a way to point somebody else's credentials at a server you control. // // The tier is candidate.write, the same as sending, because connecting a mailbox is a recruiter setting up // their own working tools rather than an administrative act over the organization. func (h *Handler) MailSyncRoutes(r chi.Router) { write := security.RequireCapability(security.CapCandidateWrite) r.With(write).Get("/mail/account", h.getMailAccount) r.With(write).Put("/mail/account", h.putMailAccount) r.With(write).Delete("/mail/account", h.deleteMailAccount) r.With(write).Post("/mail/account/test", h.testMailAccount) r.With(write).Post("/mail/account/enabled", h.setMailEnabled) r.With(write).Post("/mail/sync", h.syncMail) } func (h *Handler) getMailAccount(w http.ResponseWriter, r *http.Request) { a, found, err := h.svc.GetMailAccount(r.Context()) if err != nil { writeErr(w, http.StatusInternalServerError, "could not read the mailbox configuration", "") return } writeJSON(w, http.StatusOK, map[string]any{ "connected": found, "account": a, "note": "only message HEADERS are read, never message bodies, and correspondence with people who " + "are not in this organization is discarded and never stored", }) } func (h *Handler) putMailAccount(w http.ResponseWriter, r *http.Request) { var in MailAccountInput if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 8<<10)).Decode(&in); err != nil { writeErr(w, http.StatusBadRequest, "invalid body", "") return } if err := h.svc.SaveMailAccount(r.Context(), in, clientIPOf(r), r.UserAgent()); err != nil { h.failMail(w, err) return } writeJSON(w, http.StatusOK, map[string]any{ "saved": true, "note": "test the connection before switching sync on, an unproven mailbox never syncs", }) } func (h *Handler) deleteMailAccount(w http.ResponseWriter, r *http.Request) { if err := h.svc.DisconnectMailbox(r.Context(), clientIPOf(r), r.UserAgent()); err != nil { h.failMail(w, err) return } writeJSON(w, http.StatusOK, map[string]any{ "disconnected": true, "note": "the stored password and the sync history are gone, the activities already logged remain", }) } func (h *Handler) testMailAccount(w http.ResponseWriter, r *http.Request) { if err := h.svc.TestMailbox(r.Context()); err != nil { h.failMail(w, err) return } writeJSON(w, http.StatusOK, map[string]any{"verified": true}) } func (h *Handler) setMailEnabled(w http.ResponseWriter, r *http.Request) { var in struct { Enabled bool `json:"enabled"` } if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<10)).Decode(&in); err != nil { writeErr(w, http.StatusBadRequest, "invalid body", "") return } if err := h.svc.SetMailboxEnabled(r.Context(), in.Enabled); err != nil { h.failMail(w, err) return } writeJSON(w, http.StatusOK, map[string]any{"enabled": in.Enabled}) } func (h *Handler) syncMail(w http.ResponseWriter, r *http.Request) { res, err := h.svc.SyncMailbox(r.Context()) if err != nil { h.failMail(w, err) return } writeJSON(w, http.StatusOK, res) } func (h *Handler) failMail(w http.ResponseWriter, err error) { switch { case errors.Is(err, ErrMailboxNotConfigured): writeErr(w, http.StatusNotFound, "no mailbox is connected", "") case errors.Is(err, ErrMailInvalid): // A configuration mistake, not a transport failure. These shared a message prefix with connection // errors, so every validation error reported itself as a bad gateway until a sentinel separated them. writeErr(w, http.StatusUnprocessableEntity, strings.TrimPrefix(err.Error(), "recruiting: "), "") case errors.Is(err, ErrMailboxUnverified): writeErr(w, http.StatusConflict, "this mailbox has not been verified, test the connection before switching sync on", "") case strings.Contains(err.Error(), "recruiting: "): // The transport's own words, already scrubbed of the credentials by the service, because "could not // connect" sends somebody to a support queue while "no such host" sends them to their typo. writeErr(w, http.StatusBadGateway, strings.TrimPrefix(err.Error(), "recruiting: "), "") default: writeErr(w, http.StatusInternalServerError, "could not reach the mailbox", "") } } func clientIPOf(r *http.Request) string { return r.Header.Get("X-Forwarded-For") }