Source identity
| Field | Recorded value |
|---|---|
| Source type | GitHub source |
| Original record | Janady13/missionmatch / internal/modules/recruiting/messaging_handler.go |
| Date | Not specified in source |
| Repository | Janady13/missionmatch |
| Revision | 720d47fae476b0d38477ae4401697d372b1ea2bc |
| Branch | feature/opportunity-to-outcome |
| Path or archive member | internal/modules/recruiting/messaging_handler.go |
| Original SHA-256 | 59a89675f14176749f70fbd63ad5c5d78567b279a46d84090c1d90d2dd41720a |
| Git blob SHA | a8343f02da5b44c472d4944e98ed79422b545a19 |
| Reading-copy SHA-256 | 59a89675f14176749f70fbd63ad5c5d78567b279a46d84090c1d90d2dd41720a |
Recovered source transcript
Download complete source text ↓391 lines · 14.9 KB
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") }