Skip to content

Communications API ​

Base URL: /api/v1/communications

Staff-to-student email (migration 268). A teacher emails the students in a section they teach, an advisor their current advisees, and roles an administrator chooses their Student Lists and Cohorts. Mail is not sent during the request: a send is queued in an outbox and delivered by a worker in the API, one message per recipient, so no student sees another's address.

The rules every send obeys ​

Recipients are chosen by source, and each source has its own permission, checked against the caller's acting role on top of the COMMUNICATIONS.COMPOSE route gate:

Source typeidPermissionRecipient limit
SECTIONsection id — one the caller teachesCOMMUNICATIONS.EMAIL_CLASSNever limited
ADVISEESomittedCOMMUNICATIONS.EMAIL_ADVISEESNever limited
STUDENT_LISTlist idCOMMUNICATIONS.EMAIL_LISTSThe acting role's cap
COHORTcohort idCOMMUNICATIONS.EMAIL_LISTSThe acting role's cap

One request may mix sources (up to 50). Students are deduplicated across them. A source the caller may not use is refused with 403; a Student List the caller cannot see, or a cohort that does not exist or is inactive, is 404. Superusers hold every source, may email any section, and have no cap.

The cap applies only to students reached through lists and cohorts. It is max_recipients in core.role_email_policies for the acting role, compared against the number of distinct students those sources reach. A role that holds COMMUNICATIONS.EMAIL_LISTS but has no policy row is refused (403) — a missing cap never means "unlimited". A row with a NULL cap does.

The From address must be one the caller is offered by GET /sender-options:

kindSource
OWNThe caller's own current addresses that are at a protected institution domain
SHAREDActive shared mailboxes assigned to the caller (core.sender_address_grants)
NOREPLYThe EMAIL_NOREPLY_ADDRESS setting, when the acting role has can_use_noreply (or the caller is a superuser)

Anything else is 403. A message sent from the no-reply address has a "do not reply" footer appended.

Recipients are reached at one address each: a current SCHOOL address (primary first), else the current primary, else any current address. A student with none is reported as skipped (NO_EMAIL), never silently dropped. Two students sharing an address produce one message to it.

Sending is refused while impersonating (403).

The body is HTML. It is sanitized on the way in (sanitizehtml.CleanEmail): paragraphs, emphasis, lists, links, h2/h3 headings and tables (table, thead, tbody, tr, th, td) survive; every attribute is dropped except link targets and, on th/td, integer colspan/rowspan from 1 to 20; anything that could run in a mail client does not. Tables then get fixed inline borders and padding (email.InlineTableStyles), because mail clients ignore <style> and the sender's own styling has just been removed.

Attachments are limited in layers (migration 305, internal/shared/emailattach): an organizational ceiling (core.email_attachment_limits) intersected with the policy of each surface the message's sources use (core.email_attachment_policies):

Source typeAttachment surface
SECTIONCOMPOSE.SECTION
ADVISEESCOMPOSE.ADVISEES
STUDENT_LIST, COHORTCOMPOSE.LISTS

Intersecting means only types every layer allows and the smallest of each number, so a message that mixes sources gets the strictest of its surfaces. Every compose surface is seeded off. Superusers are held to the limits too. Whatever the settings, a hard denylist (executables and scripts, macro-enabled Office files, HTML/SVG/XML, archives) is always refused, a file's bytes must match its extension (a renamed executable is refused), empty files are refused, and the client's declared content type is ignored in favour of one derived from the extension. See Email Sending.

Endpoints Overview ​

Composing ​

Gated on COMMUNICATIONS.COMPOSE.

MethodEndpointDescription
GET/sender-optionsFrom addresses, usable sources and the caller's cap
GET/my-termsTerms in which the caller teaches (ending within the last year or later)
GET/my-sections?term_id=Sections the caller teaches in a term
GET/student-listsStudent Lists the caller may email (requires EMAIL_LISTS)
GET/cohortsActive cohorts (requires EMAIL_LISTS)
POST/recipients/previewCount who a set of sources would reach, without sending
POST/messagesQueue a message

Sent mail ​

Gated on COMMUNICATIONS.SENT. Only the caller's own messages are listed; another sender's message answers 404.

MethodEndpointDescription
GET/messagesThe caller's sent messages, paginated
GET/messages/{messageID}One message with its body, per-recipient status and attachments
GET/messages/{messageID}/attachments/{attachmentID}Download a file attached to one of the caller's messages

A student's communication history ​

Gated on COMMUNICATIONS.STUDENT_HISTORY (migration 272), which is never inherited. The grant's scope decides which students: an advisor's advisees, an academic administrator's units, the registrar everyone. A student outside the scope answers 404.

MethodEndpointDescription
GET/students/{studentID}/messagesEvery staff email that reached one student, paginated
GET/students/{studentID}/messages/{messageID}/attachments/{attachmentID}Download a file from a message that student received

Administration ​

Gated on ADMIN.EMAIL_SENDING. Reads need only the node; every write also requires an acting superuser (403 otherwise).

MethodEndpointDescription
GET/role-policiesEvery role with its email policy
PUT/role-policies/{roleID}Set a role's cap and no-reply permission
DELETE/role-policies/{roleID}Remove a role's policy (its list sends are refused again)
GET/sender-addressesShared mailboxes and who may send as each
POST/sender-addressesCreate a shared mailbox
PUT/sender-addresses/{senderAddressID}Update a shared mailbox
DELETE/sender-addresses/{senderAddressID}Delete a shared mailbox and its assignments
POST/sender-addresses/{senderAddressID}/grantsAssign the mailbox to a person
DELETE/sender-addresses/{senderAddressID}/grants/{grantID}Remove an assignment
GET/attachment-limitsThe ceiling, every surface's policy and effective limits, and the denied types
PUT/attachment-limits/ceilingSet the organizational ceiling
PUT/attachment-limits/surfaces/{surfaceCode}Set one surface's policy

Composing ​

Get Sender Options ​

GET /api/v1/communications/sender-options

Everything the compose screen needs before anything is chosen.

Success Response (200 OK) ​

json
{
  "from": [
    { "address": "jdoe@puc.edu", "kind": "OWN" },
    { "address": "vpaa@puc.edu", "kind": "SHARED", "display_name": "Office of the VPAA" },
    { "address": "no-reply@puc.edu", "kind": "NOREPLY" }
  ],
  "sources": ["SECTION", "STUDENT_LIST", "COHORT"],
  "max_recipients": 200,
  "lists_configured": true,
  "sender_copy_address": "jdoe@puc.edu",
  "is_impersonating": false
}
FieldDescription
sourcesThe source types the acting role may use right now
max_recipientsThe acting role's cap for lists and cohorts. null with lists_configured: true means no cap; always null for a superuser
lists_configuredfalse when the role has no policy row, so list and cohort sends will be refused
sender_copy_addressWhere copy_me would go; null when the caller has no current address

Get My Sections ​

GET /api/v1/communications/my-sections?term_id=42

term_id is required (400 without it). Terms come from GET /my-terms.

Success Response (200 OK) ​

json
[
  {
    "section_id": 9182,
    "term_id": 42,
    "course_code": "BIOL 111",
    "course_title": "Biological Foundations I",
    "section_code": "01",
    "student_count": 31
  }
]

GET /my-terms returns [{ "term_id", "term_name", "is_current" }], newest first. GET /student-lists and GET /cohorts return [{ "id", "name", "description" }]; the lists are the active ones that are public, owned by the caller, or shared with them.


Preview Recipients ​

POST /api/v1/communications/recipients/preview

A read, sent as a POST because the sources are a body. It applies exactly the checks a send would, apart from the From address, and writes nothing.

Request Body ​

json
{
  "sources": [
    { "type": "SECTION", "id": 9182 },
    { "type": "ADVISEES" },
    { "type": "COHORT", "id": 17 }
  ]
}

Success Response (200 OK) ​

json
{
  "total": 58,
  "capped_count": 24,
  "max_recipients": 200,
  "over_limit": false,
  "skipped": [
    { "person_id": "550e8400-e29b-41d4-a716-446655440000", "name": "Doe, Sam", "reason": "NO_EMAIL" }
  ],
  "sample": ["Abbott, Lee", "Baker, Ana"],
  "attachments": {
    "is_enabled": true,
    "allowed_extensions": ["jpeg", "jpg", "pdf", "png"],
    "max_file_mb": 2,
    "max_files": 2,
    "max_total_mb": 4
  }
}
FieldDescription
totalDistinct students who would receive the message
capped_countHow many of them came through lists and cohorts — the number compared with the cap
over_limittrue when capped_count exceeds max_recipients; a send would be refused
sampleUp to ten recipient names, as a sanity check
attachmentsWhat may be attached when sending to these sources: their surfaces' policies intersected with the ceiling. is_enabled: false means no files may be attached. The send enforces the same limits; this lets a client refuse a file at once

Send a Message ​

POST /api/v1/communications/messages

Request Body ​

FieldTypeRequiredDescription
sourcesobject[]Yes1–50 sources, as in the preview
fromstringYesOne of the addresses sender-options offered
subjectstringYesUp to 300 characters
body_htmlstringYesUp to 200,000 characters; sanitized
copy_mebooleanNoAlso send a copy to sender_copy_address

A message without attachments is sent as application/json with the body above. A message with attachments is sent as multipart/form-data: the same JSON object, as a string, in a part named message, and each file in a part named files. The whole request is bounded at 26 MB (the largest total any limit may allow, plus 1 MB); the effective limits are then checked file by file. Files are stored once per message (core.email_message_attachments) and attached to every recipient's copy, including the sender's.

bash
curl -X POST http://localhost:8000/api/v1/communications/messages \
  -b cookies.txt \
  -H "X-CSRF-Token: <csrf_token_from_cookie>" \
  -F 'message={"sources":[{"type":"SECTION","id":9182}],"from":"jdoe@puc.edu","subject":"Syllabus","body_html":"<p>Attached.</p>"}' \
  -F 'files=@syllabus.pdf'

Example Request ​

bash
curl -X POST http://localhost:8000/api/v1/communications/messages \
  -b cookies.txt \
  -H "X-CSRF-Token: <csrf_token_from_cookie>" \
  -H "Content-Type: application/json" \
  -d '{
    "sources": [{ "type": "SECTION", "id": 9182 }],
    "from": "jdoe@puc.edu",
    "subject": "Lab moved to Thursday",
    "body_html": "<p>This week'"'"'s lab meets on Thursday.</p>",
    "copy_me": true
  }'

Success Response (202 Accepted) ​

json
{
  "email_message_id": 311,
  "recipient_count": 31,
  "status": "QUEUED"
}

recipient_count excludes the sender's own copy.

Error Responses ​

StatusWhen
400A source is missing its id, an unknown source type, or none of the students has an address on file
403A source the acting role may not use; a section the caller does not teach; a role with EMAIL_LISTS but no policy; a From address not offered; impersonating
400 (attachments)errors.attachmentsNotAllowed (attachments are off for these sources), errors.tooManyAttachments, errors.fileTooLarge, errors.attachmentsTooLarge (the total, or the upload exceeded the request bound), errors.fileTypeNotAllowed, errors.attachmentEmpty, errors.attachmentContentMismatch (the bytes do not match the extension). Each carries a message_key and params such as limit, filename or extension
404A Student List the caller cannot see, or a cohort that does not exist or is inactive
422RECIPIENT_LIMIT_EXCEEDED: lists and cohorts would reach more students than the cap
json
{
  "error": {
    "code": "RECIPIENT_LIMIT_EXCEEDED",
    "message": "This message would reach 240 students through lists and cohorts; your role may email at most 200 at a time.",
    "message_key": "communications.recipientLimitExceeded",
    "params": { "limit": "200", "count": "240" }
  }
}

Sent Mail ​

List Sent Messages ​

GET /api/v1/communications/messages?page=1&page_size=20

Success Response (200 OK) ​

json
{
  "count": 12,
  "next": null,
  "previous": null,
  "results": [
    {
      "email_message_id": 311,
      "from_address": "jdoe@puc.edu",
      "subject": "Lab moved to Thursday",
      "sources": [{ "type": "SECTION", "id": 9182 }],
      "recipient_count": 31,
      "status": "PARTIAL",
      "sent_count": 30,
      "failed_count": 1,
      "queued_count": 0,
      "created_at": "2026-10-05T14:02:11Z",
      "completed_at": "2026-10-05T14:03:40Z"
    }
  ]
}

A message's status is QUEUED, SENDING, SENT, PARTIAL or FAILED, recomputed from its recipients. Each recipient is QUEUED, SENT or FAILED.

Get a Sent Message ​

GET /api/v1/communications/messages/{messageID}

The summary above plus body_html, reply_to, recipients (one entry per recipient with person_id, name, email_address, is_sender_copy, status, attempts, last_error and sent_at) and attachments, always an array:

json
"attachments": [
  { "attachment_id": 57, "filename": "syllabus.pdf", "content_type": "application/pdf", "size_bytes": 184320 }
]

Download an Attachment ​

GET /api/v1/communications/messages/{messageID}/attachments/{attachmentID}

The file's bytes, from one of the caller's own messages; someone else's message, or an attachment that is not on that message, answers 404. Every attachment download in this domain is sent with Content-Disposition: attachment, X-Content-Type-Options: nosniff and Cache-Control: private, no-store, so a browser saves it rather than rendering it.

Delivery ​

The outbox worker runs on every API replica (claims use FOR UPDATE SKIP LOCKED) and sends at most EMAIL_SEND_PER_MINUTE messages a minute, default 120. A failed delivery is retried after 1, 4, 9 and 16 minutes and marked FAILED after the fifth attempt.


A Student's Communication History ​

List a Student's Messages ​

GET /api/v1/communications/students/{studentID}/messages

Paginated. Each entry is the message as it reached that one student — subject, body_html, from_name, from_address, sender_name, the email_address it went to, its status, created_at, sent_at and attachments (as above). There is deliberately no recipient list: who else the message went to is not the viewer's business.

GET /api/v1/communications/students/{studentID}/messages/{messageID}/attachments/{attachmentID}

Downloads a file from a message that student received, under the same grant scope as the list. A student outside the caller's scope, or a message the student did not receive, answers 404.


Administration ​

Set a Role's Policy ​

PUT /api/v1/communications/role-policies/{roleID}

Request Body ​

FieldTypeDescription
max_recipientsinteger1–1,000,000. Required unless unlimited is true
unlimitedbooleantrue stores a NULL cap: no limit
can_use_noreplybooleanMay choose the no-reply address as From

Success Response (204 No Content) ​

GET /role-policies lists every role as { role_id, role_code, role_name, has_policy, max_recipients, can_use_noreply, holds_email_lists }. holds_email_lists lets a client flag a cap set on a role that cannot use it, or a role granted EMAIL_LISTS with no policy.

Create a Shared Mailbox ​

POST /api/v1/communications/sender-addresses
json
{ "email_address": "vpaa@puc.edu", "display_name": "Office of the VPAA", "is_active": true }

The address must be at an active, protected institution domain (400 otherwise) and is unique, case-insensitively (409). Returns 201 with the mailbox and its grants.

Assign it with POST /sender-addresses/{senderAddressID}/grants and { "person_id": "<uuid>" }, which returns the updated mailbox.

Attachment Limits ​

GET /api/v1/communications/attachment-limits
json
{
  "ceiling": {
    "is_enabled": true,
    "allowed_extensions": ["csv", "docx", "jpeg", "jpg", "pdf", "png", "pptx", "txt", "xlsx"],
    "max_file_mb": 5, "max_files": 5, "max_total_mb": 15
  },
  "surfaces": [
    {
      "surface_code": "COMPOSE.LISTS",
      "policy":    { "is_enabled": false, "allowed_extensions": ["jpeg", "jpg", "pdf", "png"], "max_file_mb": 2, "max_files": 2, "max_total_mb": 4 },
      "effective": { "is_enabled": false, "allowed_extensions": ["jpeg", "jpg", "pdf", "png"], "max_file_mb": 2, "max_files": 2, "max_total_mb": 4 }
    }
  ],
  "denied_extensions": ["7z", "app", "bat", "..."]
}

surfaces lists, in order, COMPOSE.SECTION, COMPOSE.ADVISEES, COMPOSE.LISTS, TEMPLATE and NOTES (files on student notes, migration 308). effective is the policy intersected with the ceiling. The ceiling is always reported enabled; whether anything may be attached is decided per surface.

PUT /api/v1/communications/attachment-limits/ceiling
PUT /api/v1/communications/attachment-limits/surfaces/{surfaceCode}

Both take the same body and return the whole GET response:

FieldTypeDescription
is_enabledbooleanWhether the surface allows attachments. Ignored for the ceiling
allowed_extensionsstring[]Up to 50, each up to 10 characters. Lower-cased, leading dots stripped, de-duplicated
max_file_mbinteger1–25
max_filesinteger1–20
max_total_mbinteger1–25, and not smaller than max_file_mb

A denied extension is refused, and a surface policy must fit inside the ceiling as it stands (every type allowed by the ceiling, every number no larger); violations come back as per-field validation errors. Lowering the ceiling does not rewrite surfaces that now exceed it: their effective limits shrink instead. An unknown surfaceCode is 404; there is no endpoint for adding a surface. Both writes require an acting superuser (403 otherwise).

A shared mailbox is never a person email

Do not give people a shared mailbox by adding it to their person_emails. An address at an institution domain on a person record is an identity claim — SSO links logins by it — so that would make the mailbox's own SSO login resolve to one of them. Grants exist so that this never has to happen.



Complete endpoint reference ​

Every route this domain registers, with the permission node that gates it, checked against internal/domain/communications/routes.go.

Paths are relative to /api/v1/communications.

MethodEndpointPermission nodeDescription
GET/attachment-limitsADMIN.EMAIL_SENDINGGet attachment limits
PUT/attachment-limits/ceilingADMIN.EMAIL_SENDING + superuserSet the ceiling
PUT/attachment-limits/surfaces/{surfaceCode}ADMIN.EMAIL_SENDING + superuserSet a surface policy
GET/cohortsCOMMUNICATIONS.COMPOSE (VIEW)List cohorts
GET/messagesCOMMUNICATIONS.SENTList the caller's sent messages
POST/messagesCOMMUNICATIONS.COMPOSEQueue a message
GET/messages/{messageID}COMMUNICATIONS.SENTGet one sent message
GET/messages/{messageID}/attachments/{attachmentID}COMMUNICATIONS.SENTDownload an attachment of the caller's message
GET/my-sectionsCOMMUNICATIONS.COMPOSE (VIEW)List the caller's sections in a term
GET/my-termsCOMMUNICATIONS.COMPOSE (VIEW)List the caller's teaching terms
POST/recipients/previewCOMMUNICATIONS.COMPOSE (VIEW)Preview recipients
GET/role-policiesADMIN.EMAIL_SENDINGList role email policies
DELETE/role-policies/{roleID}ADMIN.EMAIL_SENDING + superuserDelete a role's policy
PUT/role-policies/{roleID}ADMIN.EMAIL_SENDING + superuserSet a role's policy
GET/sender-addressesADMIN.EMAIL_SENDINGList shared mailboxes
POST/sender-addressesADMIN.EMAIL_SENDING + superuserCreate a shared mailbox
DELETE/sender-addresses/{senderAddressID}ADMIN.EMAIL_SENDING + superuserDelete a shared mailbox
PUT/sender-addresses/{senderAddressID}ADMIN.EMAIL_SENDING + superuserUpdate a shared mailbox
POST/sender-addresses/{senderAddressID}/grantsADMIN.EMAIL_SENDING + superuserAssign a mailbox
DELETE/sender-addresses/{senderAddressID}/grants/{grantID}ADMIN.EMAIL_SENDING + superuserRemove an assignment
GET/sender-optionsCOMMUNICATIONS.COMPOSE (VIEW)Get sender options
GET/student-listsCOMMUNICATIONS.COMPOSE (VIEW)List student lists
GET/students/{studentID}/messagesCOMMUNICATIONS.STUDENT_HISTORYList a student's messages
GET/students/{studentID}/messages/{messageID}/attachments/{attachmentID}COMMUNICATIONS.STUDENT_HISTORYDownload an attachment of a message the student received

Student Information System Documentation