Soonlife DOCS
API 레퍼런스

웹메일 API

학교 웹메일(mail.sch.ac.kr) 대행 — 메일함 목록, 본문, 첨부, 작성·발송, 별표, 휴지통

개요

학교 웹메일(mail.sch.ac.kr)을 포털에서 그대로 쓸 수 있게 중계하는 API입니다.

웹메일은 포털의 다른 모든 API와 인증 체계가 다릅니다. 종합정보시스템(st.sch.ac.kr)의 jsessionid/ecd_key로는 메일 서버에 접근할 수 없고, SSO(sso.sch.ac.kr) RSA 로그인을 따로 태워야 합니다. 응답도 XPLATFORM XML이 아니라 JSON입니다.

클라이언트 입장에서는 다른 API와 똑같이 sch_session 쿠키만 있으면 됩니다. SSO 체인은 서버가 알아서 태우고, 결과로 받은 메일 쿠키는 세션 페이로드에 담아 다시 쿠키로 내려줍니다.

엔드포인트 목록

메서드경로설명
GET/api/mail/account/대표 메일 주소 조회 (웹메일 사용 가능 여부 판별)
GET/api/mail/list/메일함 목록 (페이지네이션)
GET/api/mail/message/메일 본문 + 첨부 목록
GET/api/mail/attach/첨부파일 다운로드 (스트리밍 중계)
GET/api/mail/compose/메일 쓰기 폼 초기 데이터 (답장·전달 인용문 포함)
GET/api/mail/contacts/받는사람 자동완성 (교내 주소록 검색)
POST/api/mail/send/메일 발송
GET/api/mail/receipt/보낸 메일 수신확인 상세
POST/api/mail/delete/메일 삭제 (휴지통으로 이동)
POST/api/mail/complete-delete/완전 삭제 (되돌릴 수 없음)
POST/api/mail/restore/휴지통 메일 복구
POST/api/mail/star/별표 지정·해제 (색상 4종)

모든 엔드포인트는 sch_session 쿠키 인증이 필요합니다.


메일 세션 — 지연 수립

로그인 시점에는 SSO를 태우지 않습니다. 체인이 왕복 3번이라, 웹메일을 쓰지 않는 대다수의 로그인까지 그만큼 느려지기 때문입니다.

1. POST /api/auth/login/        → 세션 페이로드에 password 보관, mail_cookies는 빈 dict
2. GET  /api/mail/account/      → 이 요청에서 SSO 체인을 태운다 (첫 메일 요청 1회만)
                                   Set-Cookie: sch_session=<mail_cookies 포함 토큰>
3. 이후 메일 요청               → 세션에서 메일 쿠키를 바로 읽는다

세션은 서버에 상태를 두지 않는 암호화 쿠키입니다. 그래서 메일 쿠키를 새로 받은 응답에는 갱신된 sch_session 쿠키가 함께 실립니다. 이때 만료 시각(expired)은 그대로 두고 남은 시간만큼만 쿠키 수명을 주므로, 메일을 쓰는 것으로 세션이 연장되지는 않습니다.

SSO 로그인 체인 (서버 내부 동작)

  1. portal.sch.ac.kr/_custom/sch/oauth/login_portal.jsp → oauth/authorize → SSO 로그인 폼. authorize를 거쳐야 세션에 OAuth 클라이언트 컨텍스트가 등록됩니다. 로그인 폼 URL로 바로 들어가면 자격증명이 맞아도 거부됩니다.
  2. 자격증명 JSON 전체를 RSA(PKCS1v15)로 암호화해 /oa/au/auth/verify에 POST.
  3. portal.sch.ac.kr/r.jsp?url=<메일 oAuthCheckUser.do> 브리지가 메일 서버가 신뢰하는 SCHDR/SCHDRS 쿠키를 sch.ac.kr 도메인에 심습니다. 브리지를 건너뛰면 메일 자체 로그인 페이지로 튕깁니다.

요청 하나당 상한(6초)과 체인 전체 예산(10초)을 따로 둡니다. 요청별 타임아웃만 두면 왕복이 누적돼 화면이 1분 가까이 멈추기 때문입니다.


메일함 코드

개별 메일함 코드는 계정마다 다릅니다. 대표 주소의 @ 앞부분(mailbox_id)을 붙여 만듭니다.

folder 값메일함실제 코드mailbox_id
all (기본)전체메일함TOTAL선택
inbox받은메일함Inbox_<id>필수
self내게쓴메일함Tome_<id>필수
sent보낸메일함Sent_<id>필수
draft임시보관함Drafts_<id>필수
spam스팸메일함Spam_<id>필수
trash휴지통Trash_<id>필수
star1~star4별표 골라보기STAR1~STAR4불필요

별표 색상: 1=노랑, 2=파랑, 3=빨강, 4=초록. 골라보기 폴더 코드는 계정과 무관한 고정값입니다.

mailbox_id는 서버 세션에 저장하지 않습니다. 클라이언트가 /api/mail/account/로 한 번 받아 두고 이후 요청마다 넘깁니다. folder=all일 때만 없어도 조회되며, 이때 mailbox_id는 전체메일함이 빠뜨리는 내게쓴메일함 항목을 1페이지에 보충하는 데 쓰입니다.


공통 에러 코드

에러 코드HTTP설명
UNAUTHORIZED401sch_session 쿠키 없음 또는 만료
MAIL_SESSION_EXPIRED401메일 쿠키 만료 — 세션에서 지워지므로 그대로 재시도하면 복구됩니다
MAIL_UNAVAILABLE503메일함이 없는 계정이거나, 지연 수립 도입 전에 발급된 세션(비밀번호 없음). 재시도로 풀리지 않고 재로그인이 필요합니다
MAIL_FAILED503메일 서버 호출 실패 (그 외)

MAIL_SESSION_EXPIRED와 MAIL_UNAVAILABLE은 대응이 정반대입니다. 프론트엔드는 MailApiError.needsRelogin(코드가 MAIL_UNAVAILABLE일 때 true)으로 둘을 갈라 안내합니다.


GET /api/mail/account/

대표 메일 주소를 반환합니다. 웹메일 진입 시 프론트엔드가 가장 먼저 호출하는 엔드포인트이며, 보통 이 요청이 메일 SSO 세션을 실제로 수립합니다.

응답 — 200 OK

{
  "address": "hong@sch.ac.kr"
}

주소의 @ 앞부분이 이후 요청에 쓰는 mailbox_id입니다.

에러 응답

주소를 읽지 못하면 이 계정에는 메일함이 없다는 뜻이므로 MAIL_UNAVAILABLE (503)을 반환합니다. 프론트엔드는 이 응답으로 웹메일 사용 가능 여부를 판단합니다.

목록 조회를 이 요청과 나란히 보내지 마세요. SSO 체인을 두 번 태우게 됩니다. 프론트엔드는 이 응답을 받은 뒤에 목록을 조회합니다.


GET /api/mail/list/

쿼리 파라미터

파라미터타입필수설명
folderstring아니오위 메일함 코드 표의 folder 값. 기본 all
mailbox_idstring조건부개별 메일함(inbox~trash)은 필수
pagenumber아니오페이지 번호. 기본 1

응답 — 200 OK

{
  "items": [
    {
      "id": "12345",
      "ukey": "12345",
      "mailbox": "받은메일함",
      "from": { "name": "학사팀", "address": "academic@sch.ac.kr" },
      "to": [{ "name": "홍길동", "address": "hong@sch.ac.kr" }],
      "subject": "2026학년도 2학기 수강신청 안내",
      "preview": "",
      "date": "2026-09-01T09:30:00+09:00",
      "unread": true,
      "star": 0,
      "starred": false,
      "size": 24576,
      "has_attachment": true,
      "read_receipt": null
    }
  ],
  "page": 1,
  "pages": 12,
  "total": 237,
  "has_next": true
}
필드타입설명
items[].ukeystring메일 식별자 — 본문·삭제·별표 등 모든 후속 요청의 키
items[].mailboxstring이 메일이 실제로 들어있는 메일함 이름. 전체메일함에서 출처 표시용
items[].previewstring목록 응답에 미리보기 필드가 없어 보통 빈 문자열
items[].starnumber0=없음, 1~4=색상
items[].sizenumber바이트
items[].read_receiptobject | null보낸메일함에서만 채워짐 — { read, date }
pagesnumber전체 페이지 수
has_nextboolean다음 페이지 존재 여부

받는사람(to)은 목록 응답에 없는 계정이 많습니다. 본문을 열면 정확한 값이 옵니다.

전체메일함(TOTAL)이 원인 불명으로 내게쓴메일함 항목을 빼놓고 내려주는 계정이 있어, folder=all의 1페이지에 한해 내게쓴메일함을 따로 끌어와 합쳐 보충합니다.

에러 응답

에러 코드HTTP설명
UNKNOWN_FOLDER400알 수 없는 folder 값
MISSING_MAILBOX_ID400개별 메일함인데 mailbox_id 누락

GET /api/mail/message/

쿼리 파라미터

파라미터타입필수설명
ukeystringO목록 항목의 ukey

응답 — 200 OK

{
  "ukey": "12345",
  "subject": "2026학년도 2학기 수강신청 안내",
  "from": { "name": "학사팀", "address": "academic@sch.ac.kr" },
  "to": [{ "name": "홍길동", "address": "hong@sch.ac.kr" }],
  "date": "2026-09-01T09:30:00+09:00",
  "spam_reason": "",
  "body_html": "<p>...</p>",
  "body_text": "...",
  "is_rich": true,
  "attachments": [
    {
      "id": "12345-0",
      "name": "수강신청_안내.pdf",
      "size": 182400,
      "mail_key": "12345",
      "aindex": 0,
      "mindex": -1
    }
  ],
  "mail_header": "Received: from ...",
  "body_source": "<html>...</html>"
}
필드타입설명
body_htmlstring서버에서 정제한 본문 HTML (스크립트·주석 제거)
body_textstring본문을 평문으로 변환한 값
is_richboolean서식 있는 본문인지
spam_reasonstring스팸으로 분류된 이유 (없으면 빈 문자열)
mail_header / body_sourcestring원문 보기용 헤더와 정제 전 본문. 화면에는 텍스트로만 출력

에러 응답

에러 코드HTTP설명
MISSING_UKEY400ukey 누락

GET /api/mail/attach/

첨부파일을 내려받습니다. 메일 쿠키가 HttpOnly 세션 안에 있어 브라우저가 메일 서버에서 직접 받을 수 없으므로 서버가 중계하되, 파일을 메모리에 담지 않고 스트리밍합니다.

쿼리 파라미터

파라미터타입필수설명
mail_keystringOattachments[].mail_key
aindexnumber아니오메일 안에서 첨부의 순번. 기본 0
mindexnumber아니오중첩 메일 속 첨부의 위치. 보통 -1
namestring아니오저장될 파일명

첨부는 파일명이나 fileKey가 아니라 mail_key·aindex·mindex 세 값으로 고릅니다. 세 값 모두 본문 조회 응답의 attachments[]에 실려 옵니다.

응답 — 200 OK

본문은 파일 바이트이며, 헤더는 다음과 같습니다.

Content-Type: <업스트림 Content-Type>
Content-Disposition: attachment; filename="ASCII 이름"; filename*=UTF-8''%ED%95%9C%EA%B8%80
  • 저장될 파일명은 클라이언트가 보낸 name을 먼저 씁니다. 그 값이 곧 화면에 보였던 이름이라 인코딩이 온전한 반면, 메일 서버의 Content-Disposition은 인코딩 선언 없이 오는 경우가 있어 폴백으로만 둡니다. 한글은 filename*(RFC 5987)로 실립니다.
  • 경로 구분자·제어문자·따옴표는 _로 치환되고 200자로 잘립니다. 파일명은 저장될 이름일 뿐 어떤 파일을 주는지는 바꾸지 못합니다.
  • 업스트림에 Accept-Encoding: identity를 보내고, 그래도 압축돼 오면 Content-Length를 넘기지 않습니다. requests가 압축을 풀어 넘기므로 길이가 어긋나기 때문입니다.

에러 응답

에러 코드HTTP설명
MISSING_MAIL_KEY400mail_key 누락
INVALID_ATTACHMENT400aindex/mindex가 정수가 아님
MAIL_SESSION_EXPIRED401파일 대신 로그인 페이지가 온 경우

세션이 끊기면 파일 대신 로그인 페이지가 옵니다. 첨부 자체가 .html일 수도 있어 Content-Type만으로는 가를 수 없으므로, 로그인 URL로 튕겼거나 Content-Disposition 없는 HTML인 경우를 만료로 봅니다.

프론트엔드는 링크가 아니라 blob으로 받습니다(downloadAttachment). 링크로 열면 만료·권한 오류 JSON이 새 탭에 그대로 뜹니다.


GET /api/mail/compose/

메일 쓰기 폼의 초기 데이터를 반환합니다.

쿼리 파라미터

파라미터타입필수설명
source_ukeystring아니오답장·전달할 원본 메일의 ukey. 주면 제목(RE:/FW:)과 인용문까지 채워집니다
modestring아니오reply(기본) 또는 forward. 답장은 받는사람도 원본 발신자로 채워지고, 전달은 비어 옵니다

응답 — 200 OK

{
  "from_name": "홍길동",
  "from_addresses": ["hong@sch.ac.kr"],
  "deny_send": false,
  "max_recipients": 100,
  "source_ukey": "12345",
  "mode": "reply",
  "to": "academic@sch.ac.kr",
  "subject": "RE: 2026학년도 2학기 수강신청 안내",
  "quoted_html": "<div>...</div>"
}
필드타입설명
from_addressesstring[]발신 주소 후보
deny_sendboolean발송이 막힌 계정인지 — 폼을 열기 전에 안내할 수 있게
max_recipientsnumber한 통에 넣을 수 있는 받는사람 수
source_ukey / mode / to / subject / quoted_html—답장·전달일 때만 채워집니다 (새 메일이면 전부 빈 값)

tempKey는 요청마다 새로 발급되는 1회용 키라 여기서 내려주지 않습니다. 발송 시점에 서버가 새로 받아 씁니다.

에러 응답

에러 코드HTTP설명
UNKNOWN_MODE400mode가 reply/forward가 아님

GET /api/mail/contacts/

받는사람 자동완성 — 교내 구성원을 이름·아이디·주소 조각으로 찾습니다.

쿼리 파라미터

파라미터타입필수설명
qstring아니오검색어. 한 글자부터 찾고, 비어 있으면 빈 목록

응답 — 200 OK

{
  "items": [
    {
      "name": "홍길동",
      "email": "hong@sch.ac.kr",
      "dept": "컴퓨터소프트웨어공학과",
      "position": "학생",
      "userid": "hong"
    }
  ]
}

주소(email)가 없는 후보는 받는사람에 넣을 수 없어 제외됩니다.

타이핑마다 호출되는 경로라 실패해도 화면을 막지 않습니다. 프론트엔드는 오류를 조용히 삼키고 드롭다운만 닫으며, 앞선 요청은 AbortController로 끊어 오래된 결과가 최신 검색어를 덮지 않게 합니다.


POST /api/mail/send/

요청 본문

필드타입필수설명
from_addrstringO보내는 주소 (/api/mail/compose/의 from_addresses 중 하나)
from_namestring아니오보내는 사람 표시 이름
tostringO받는사람. 쉼표·세미콜론·줄바꿈으로 여러 명
cc / bccstring아니오참조 / 숨은참조
subjectstring아니오제목
bodystring아니오본문 HTML — 서식 편집기 출력. 발송 직전 서버가 한 번 더 정제합니다
source_ukeystring아니오주면 그 메일에 대한 답장·전달로 보냅니다
{
  "from_addr": "hong@sch.ac.kr",
  "from_name": "홍길동",
  "to": "academic@sch.ac.kr, dean@sch.ac.kr",
  "cc": "",
  "bcc": "",
  "subject": "수강신청 문의",
  "body": "<div>안녕하세요.</div>",
  "source_ukey": ""
}

응답 — 200 OK

{ "sent": true }

에러 응답

에러 코드HTTP설명
MISSING_TO400to가 빈 값
MISSING_FROM400from_addr가 빈 값
MAIL_FAILED503메일 서버가 발송을 거부 — 사유가 message에 담깁니다

GET /api/mail/receipt/

보낸 메일 한 통의 수신확인 상세입니다.

쿼리 파라미터

파라미터타입필수설명
ukeystringO보낸메일함 행의 ukey

응답 — 200 OK

{
  "read": true,
  "date": "2026-09-01T14:20:00+09:00",
  "read_count": 2,
  "total_count": 3
}

목록 행의 read_receipt만으로는 참조가 섞인 메일이 몇 명 중 몇 명에게 읽혔는지 알 수 없어(뭉뚱그려 "모두읽음"으로만 옵니다), 상세를 열 때 이 엔드포인트로 한 번 더 확인합니다.

에러 응답

에러 코드HTTP설명
MISSING_UKEY400ukey 누락
RECEIPT_NOT_FOUND404수신확인 정보 없음 — 프론트엔드는 목록의 요약으로 조용히 대체합니다

POST /api/mail/delete/

폴더 구분 없이 휴지통으로 옮깁니다(delete.do에 folder 파라미터가 없습니다).

요청 본문

필드타입필수설명
ukeysstring[]조건부삭제할 메일 목록
ukeystring조건부단건 삭제 시. ukeys가 있으면 무시됩니다
{ "ukeys": ["12345", "12346"] }

응답 — 200 OK

{ "deleted": 2 }

에러 응답

에러 코드HTTP설명
MISSING_UKEY400ukeys·ukey 모두 비어 있음

POST /api/mail/complete-delete/

휴지통에서 되돌릴 수 없이 지웁니다.

요청 본문

필드타입필수설명
ukeysstring[]O1개 이상

응답 — 200 OK

{ "deleted": 2 }

에러 응답

에러 코드HTTP설명
MISSING_UKEYS400ukeys가 비어 있음

복구 수단이 없습니다. 프론트엔드는 확인 다이얼로그를 거친 뒤에만 호출합니다.


POST /api/mail/restore/

휴지통 메일을 받은메일함으로 되돌립니다. 원래 있던 메일함 정보는 휴지통에 들어오면서 사라지므로 항상 받은메일함으로 갑니다.

요청 본문

필드타입필수설명
ukeysstring[]O1개 이상
mailbox_idstringO폴더 코드에 계정 아이디가 붙어야 하므로 필요

응답 — 200 OK

{ "restored": 2 }

에러 응답

에러 코드HTTP설명
MISSING_UKEYS400ukeys가 비어 있음
MISSING_MAILBOX_ID400mailbox_id 누락

POST /api/mail/star/

요청 본문

필드타입필수설명
ukeystringO대상 메일
starnumberO0=해제, 1=노랑, 2=파랑, 3=빨강, 4=초록

응답 — 200 OK

{ "ukey": "12345", "star": 2 }

에러 응답

에러 코드HTTP설명
MISSING_UKEY400ukey 누락
INVALID_STAR400star가 0~4가 아님

프론트엔드는 화면을 먼저 바꿔 놓고(낙관적 업데이트) 실패했을 때만 되돌립니다.

On this page