feat(project): 아사나식 프로젝트 관리 모듈 추가

- project_db 신규(projects/members/stages/tasks/comments/activity, project_app CRUD)
- 프로젝트/서브프로젝트(self-FK), 업무·진행단계(칸반), 멤버 배정
- 메인 뷰 달력(FullCalendar)/타임라인(vis-timeline) 토글 + 보드/리스트
- 진입은 로그인 회사 직원 전원, 생성/배정은 관리자 전용
- 업무 배정·완료 시 관리자 메일 알림(app/mail.py, SMTP_* env, 미설정 시 skip)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-25 11:22:05 +09:00
parent 8d7fe01c2d
commit c192d61c71
14 changed files with 2404 additions and 1 deletions
+97
View File
@@ -0,0 +1,97 @@
"""SMTP 메일 발송 유틸 (표준 라이브러리 smtplib 만 사용 — 추가 의존성 없음).
모든 모듈이 공용으로 쓰는 가벼운 발송기. 현재는 프로젝트 관리 모듈의
관리자 알림(업무 배정/완료)에 사용한다.
환경변수:
SMTP_HOST SMTP 서버 호스트 (미설정 시 발송 비활성 — 조용히 skip)
SMTP_PORT 포트 (기본 587)
SMTP_USER 로그인 사용자 (없으면 익명)
SMTP_PASSWORD 로그인 비밀번호
SMTP_FROM 보내는 사람 (미설정 시 SMTP_USER)
SMTP_TLS "true"(STARTTLS, 기본) | "ssl"(SMTPS/465) | "false"(평문)
SMTP_TIMEOUT 연결 타임아웃 초 (기본 10)
설계 원칙:
- 미설정/실패해도 앱이 죽지 않는다. 로그만 남기고 False 반환.
- 호출부는 FastAPI BackgroundTasks 로 비동기 호출 권장(요청 지연 방지).
"""
from __future__ import annotations
import logging
import os
import smtplib
from email.message import EmailMessage
from email.utils import formataddr
logger = logging.getLogger("app.mail")
def _env(name: str, default: str = "") -> str:
return (os.getenv(name, "") or "").strip() or default
def mail_enabled() -> bool:
"""SMTP_HOST 가 있으면 발송 가능으로 본다."""
return bool(_env("SMTP_HOST"))
def send_email(
*,
to: list[str] | str,
subject: str,
body: str,
html: str | None = None,
from_name: str = "DBX ERP",
) -> bool:
"""단순 텍스트(+선택 HTML) 메일 1건 발송.
반환: 성공 True / 비활성·실패 False. 예외를 밖으로 던지지 않는다.
"""
host = _env("SMTP_HOST")
if not host:
logger.info("SMTP 미설정 — 메일 발송 skip (subject=%s)", subject)
return False
recipients = [to] if isinstance(to, str) else list(to)
recipients = [r.strip() for r in recipients if r and r.strip()]
if not recipients:
logger.info("수신자 없음 — 메일 발송 skip (subject=%s)", subject)
return False
port = int(_env("SMTP_PORT", "587"))
user = _env("SMTP_USER")
password = _env("SMTP_PASSWORD")
sender = _env("SMTP_FROM", user or "no-reply@dbxcorp.co.kr")
mode = _env("SMTP_TLS", "true").lower()
timeout = int(_env("SMTP_TIMEOUT", "10"))
msg = EmailMessage()
msg["Subject"] = subject
msg["From"] = formataddr((from_name, sender))
msg["To"] = ", ".join(recipients)
msg.set_content(body)
if html:
msg.add_alternative(html, subtype="html")
try:
if mode == "ssl":
with smtplib.SMTP_SSL(host, port, timeout=timeout) as server:
if user:
server.login(user, password)
server.send_message(msg)
else:
with smtplib.SMTP(host, port, timeout=timeout) as server:
server.ehlo()
if mode != "false":
server.starttls()
server.ehlo()
if user:
server.login(user, password)
server.send_message(msg)
logger.info("메일 발송 완료 → %s (subject=%s)", recipients, subject)
return True
except Exception as exc: # noqa: BLE001 — 메일 실패가 앱을 막으면 안 됨
logger.warning("메일 발송 실패 (subject=%s): %s", subject, exc)
return False