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 type | id | Permission | Recipient limit |
|---|---|---|---|
SECTION | section id — one the caller teaches | COMMUNICATIONS.EMAIL_CLASS | Never limited |
ADVISEES | omitted | COMMUNICATIONS.EMAIL_ADVISEES | Never limited |
STUDENT_LIST | list id | COMMUNICATIONS.EMAIL_LISTS | The acting role's cap |
COHORT | cohort id | COMMUNICATIONS.EMAIL_LISTS | The 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:
kind | Source |
|---|---|
OWN | The caller's own current addresses that are at a protected institution domain |
SHARED | Active shared mailboxes assigned to the caller (core.sender_address_grants) |
NOREPLY | The 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 type | Attachment surface |
|---|---|
SECTION | COMPOSE.SECTION |
ADVISEES | COMPOSE.ADVISEES |
STUDENT_LIST, COHORT | COMPOSE.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.
| Method | Endpoint | Description |
|---|---|---|
| GET | /sender-options | From addresses, usable sources and the caller's cap |
| GET | /my-terms | Terms 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-lists | Student Lists the caller may email (requires EMAIL_LISTS) |
| GET | /cohorts | Active cohorts (requires EMAIL_LISTS) |
| POST | /recipients/preview | Count who a set of sources would reach, without sending |
| POST | /messages | Queue a message |
Sent mail
Gated on COMMUNICATIONS.SENT. Only the caller's own messages are listed; another sender's message answers 404.
| Method | Endpoint | Description |
|---|---|---|
| GET | /messages | The 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.
| Method | Endpoint | Description |
|---|---|---|
| GET | /students/{studentID}/messages | Every 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).
| Method | Endpoint | Description |
|---|---|---|
| GET | /role-policies | Every 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-addresses | Shared mailboxes and who may send as each |
| POST | /sender-addresses | Create 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}/grants | Assign the mailbox to a person |
| DELETE | /sender-addresses/{senderAddressID}/grants/{grantID} | Remove an assignment |
| GET | /attachment-limits | The ceiling, every surface's policy and effective limits, and the denied types |
| PUT | /attachment-limits/ceiling | Set the organizational ceiling |
| PUT | /attachment-limits/surfaces/{surfaceCode} | Set one surface's policy |
Composing
Get Sender Options
GET /api/v1/communications/sender-optionsEverything the compose screen needs before anything is chosen.
Success Response (200 OK)
{
"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
}| Field | Description |
|---|---|
sources | The source types the acting role may use right now |
max_recipients | The acting role's cap for lists and cohorts. null with lists_configured: true means no cap; always null for a superuser |
lists_configured | false when the role has no policy row, so list and cohort sends will be refused |
sender_copy_address | Where copy_me would go; null when the caller has no current address |
Get My Sections
GET /api/v1/communications/my-sections?term_id=42term_id is required (400 without it). Terms come from GET /my-terms.
Success Response (200 OK)
[
{
"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/previewA 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
{
"sources": [
{ "type": "SECTION", "id": 9182 },
{ "type": "ADVISEES" },
{ "type": "COHORT", "id": 17 }
]
}Success Response (200 OK)
{
"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
}
}| Field | Description |
|---|---|
total | Distinct students who would receive the message |
capped_count | How many of them came through lists and cohorts — the number compared with the cap |
over_limit | true when capped_count exceeds max_recipients; a send would be refused |
sample | Up to ten recipient names, as a sanity check |
attachments | What 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/messagesRequest Body
| Field | Type | Required | Description |
|---|---|---|---|
sources | object[] | Yes | 1–50 sources, as in the preview |
from | string | Yes | One of the addresses sender-options offered |
subject | string | Yes | Up to 300 characters |
body_html | string | Yes | Up to 200,000 characters; sanitized |
copy_me | boolean | No | Also 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.
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
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)
{
"email_message_id": 311,
"recipient_count": 31,
"status": "QUEUED"
}recipient_count excludes the sender's own copy.
Error Responses
| Status | When |
|---|---|
400 | A source is missing its id, an unknown source type, or none of the students has an address on file |
403 | A 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 |
404 | A Student List the caller cannot see, or a cohort that does not exist or is inactive |
422 | RECIPIENT_LIMIT_EXCEEDED: lists and cohorts would reach more students than the cap |
{
"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=20Success Response (200 OK)
{
"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:
"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}/messagesPaginated. 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
| Field | Type | Description |
|---|---|---|
max_recipients | integer | 1–1,000,000. Required unless unlimited is true |
unlimited | boolean | true stores a NULL cap: no limit |
can_use_noreply | boolean | May 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{ "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{
"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:
| Field | Type | Description |
|---|---|---|
is_enabled | boolean | Whether the surface allows attachments. Ignored for the ceiling |
allowed_extensions | string[] | Up to 50, each up to 10 characters. Lower-cased, leading dots stripped, de-duplicated |
max_file_mb | integer | 1–25 |
max_files | integer | 1–20 |
max_total_mb | integer | 1–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.
Related
- Email Sending (admin guide)
- Emailing Students (user guide)
- Core API — the
EMAIL_NOREPLY_ADDRESSsetting - People API — person emails; an institutional address (type
SCHOOL, or at a protected institution domain) can be created, changed or retired only by an acting superuser
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.
| Method | Endpoint | Permission node | Description |
|---|---|---|---|
| GET | /attachment-limits | ADMIN.EMAIL_SENDING | Get attachment limits |
| PUT | /attachment-limits/ceiling | ADMIN.EMAIL_SENDING + superuser | Set the ceiling |
| PUT | /attachment-limits/surfaces/{surfaceCode} | ADMIN.EMAIL_SENDING + superuser | Set a surface policy |
| GET | /cohorts | COMMUNICATIONS.COMPOSE (VIEW) | List cohorts |
| GET | /messages | COMMUNICATIONS.SENT | List the caller's sent messages |
| POST | /messages | COMMUNICATIONS.COMPOSE | Queue a message |
| GET | /messages/{messageID} | COMMUNICATIONS.SENT | Get one sent message |
| GET | /messages/{messageID}/attachments/{attachmentID} | COMMUNICATIONS.SENT | Download an attachment of the caller's message |
| GET | /my-sections | COMMUNICATIONS.COMPOSE (VIEW) | List the caller's sections in a term |
| GET | /my-terms | COMMUNICATIONS.COMPOSE (VIEW) | List the caller's teaching terms |
| POST | /recipients/preview | COMMUNICATIONS.COMPOSE (VIEW) | Preview recipients |
| GET | /role-policies | ADMIN.EMAIL_SENDING | List role email policies |
| DELETE | /role-policies/{roleID} | ADMIN.EMAIL_SENDING + superuser | Delete a role's policy |
| PUT | /role-policies/{roleID} | ADMIN.EMAIL_SENDING + superuser | Set a role's policy |
| GET | /sender-addresses | ADMIN.EMAIL_SENDING | List shared mailboxes |
| POST | /sender-addresses | ADMIN.EMAIL_SENDING + superuser | Create a shared mailbox |
| DELETE | /sender-addresses/{senderAddressID} | ADMIN.EMAIL_SENDING + superuser | Delete a shared mailbox |
| PUT | /sender-addresses/{senderAddressID} | ADMIN.EMAIL_SENDING + superuser | Update a shared mailbox |
| POST | /sender-addresses/{senderAddressID}/grants | ADMIN.EMAIL_SENDING + superuser | Assign a mailbox |
| DELETE | /sender-addresses/{senderAddressID}/grants/{grantID} | ADMIN.EMAIL_SENDING + superuser | Remove an assignment |
| GET | /sender-options | COMMUNICATIONS.COMPOSE (VIEW) | Get sender options |
| GET | /student-lists | COMMUNICATIONS.COMPOSE (VIEW) | List student lists |
| GET | /students/{studentID}/messages | COMMUNICATIONS.STUDENT_HISTORY | List a student's messages |
| GET | /students/{studentID}/messages/{messageID}/attachments/{attachmentID} | COMMUNICATIONS.STUDENT_HISTORY | Download an attachment of a message the student received |