smtplib 첨부 메일 실습: 39바이트 미리보기와 중복 발송 방지

첨부 메일 자동화에서 EML 파일 생성, SMTP 서버의 접수, 실제 받은편지함 도착은 서로 다른 결과입니다. 이 실습은 첫 단계와 요청 순서를 확인합니다. 메일은 실제로 발송하지 않았습니다. 받는 사람과 첨부가 바뀌지 않았는지 먼저 확인하고, 한 번 시도한 기록이 있으면 자동 재전송을 막는 작은 예제를 만듭니다.

입력 보고서와 검증 범위

report.csv는 아래 ASCII 가상 자료이며 줄바꿈까지 포함해 39바이트입니다. 수량 합계는 2+3=5, 금액 합계는 2400+4500=6900입니다. 개인 정보나 실제 메일 주소는 담지 않습니다.

Item,Quantity,Amount
A,2,2400
B,3,4500

ZIP의 스크립트는 Python 표준 라이브러리만 사용합니다. .csv와 .xlsx 첨부를 허용하지만 내용이 업무적으로 맞는지 판정하지는 않습니다. 크기는 2 MB 이하로 제한하고, 읽은 바이트와 EML에서 꺼낸 바이트를 비교합니다. 테스트용 로그인 문자열은 메모리 가짜 전송기에만 전달되며 실제 자격증명이 아닙니다.

전체 코드: prepare와 send 분리

smtplib 문서는 STARTTLS 뒤에 EHLO를 다시 호출하도록 안내합니다. 코드에서도 ehlo, starttls, ehlo, login, send_message 순서를 유지합니다. EmailMessage의 add_attachment()는 첨부할 메시지를 만들어 붙이는 메서드입니다. 이 예제는 첨부가 하나인지, 직렬화 뒤 바이트가 같은지 별도로 확인합니다. send에는 검사 가능한 전송기 인수를 두되 기본값은 실제 SMTP이므로 아래 실행 안내에서는 prepare만 호출합니다.

import argparse
import hashlib
import hmac
import json
import os
import re
import smtplib
import ssl
from email import policy
from email.message import EmailMessage
from email.parser import BytesParser
from email.utils import formatdate, make_msgid
from pathlib import Path

LIMIT = 2_000_000

def address(value):
    if not re.fullmatch(r"[A-Za-z0-9.!#$%&'*+/=?^_`{|}~-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}", value):
        raise ValueError("Use one plain ASCII email address.")
    return value

def bounded_read(path, limit):
    with Path(path).open("rb") as handle:
        data = handle.read(limit + 1)
    if not data or len(data) > limit:
        raise ValueError("Empty file or practice size limit exceeded.")
    return data

def prepare(args):
    file = Path(args.file)
    mime = {
        ".csv": ("text", "csv"),
        ".xlsx": ("application", "vnd.openxmlformats-officedocument.spreadsheetml.sheet"),
    }.get(file.suffix.lower())
    if mime is None:
        raise ValueError("This practice accepts CSV or XLSX only.")
    data = bounded_read(file, LIMIT)
    msg = EmailMessage(policy=policy.SMTP)
    msg["From"] = address(args.sender)
    msg["To"] = address(args.to)
    msg["Subject"] = "Practice report"
    msg["Date"] = formatdate(localtime=True)
    msg["Message-ID"] = make_msgid(domain="example.com")
    msg.set_content("Fictional practice report. Review the attachment before sending.")
    msg.add_attachment(data, maintype=mime[0], subtype=mime[1], filename=file.name)
    raw = msg.as_bytes()
    check = BytesParser(policy=policy.default).parsebytes(raw)
    parts = list(check.iter_attachments())
    if len(parts) != 1 or parts[0].get_payload(decode=True) != data:
        raise RuntimeError("Attachment round-trip failed.")
    with Path(args.out).open("xb") as handle:
        handle.write(raw)
    print(json.dumps({
        "mode": "PREVIEW_ONLY", "attachments": 1,
        "attachment_name": file.name, "attachment_bytes": len(data),
        "attachment_sha256": hashlib.sha256(data).hexdigest(),
        "preview_sha256": hashlib.sha256(raw).hexdigest(),
        "smtp_connections": 0, "file": args.out,
    }))

def send(args, smtp_factory=smtplib.SMTP):
    raw = bounded_read(args.preview, LIMIT * 2)
    if not hmac.compare_digest(hashlib.sha256(raw).hexdigest(), args.sha256.lower()):
        raise ValueError("Preview hash differs. Review it again; do not send.")
    msg = BytesParser(policy=policy.default).parsebytes(raw)
    if len(msg.get_all("From", [])) != 1 or len(msg.get_all("To", [])) != 1:
        raise ValueError("Exactly one From and one To header are required.")
    sender, recipient = address(str(msg["From"])), address(str(msg["To"]))
    if msg.get("Cc") or msg.get("Bcc") or any(k.lower().startswith("resent-") for k in msg.keys()):
        raise ValueError("Only one directly addressed recipient is supported.")
    if recipient != args.confirm_to or len(list(msg.iter_attachments())) != 1:
        raise ValueError("Recipient confirmation or attachment count mismatch.")
    for value in (sender, recipient):
        domain = value.rsplit("@", 1)[1].lower()
        if any(domain == root or domain.endswith("." + root) for root in ("example.com", "example.net", "example.org")) or domain.endswith(".example"):
            raise ValueError("Example addresses cannot be used for sending.")
    host = os.environ["SMTP_HOST"].strip()
    user, password = os.environ["SMTP_USER"], os.environ["SMTP_PASSWORD"]
    port = int(os.environ.get("SMTP_PORT", "587"))
    if not host or not user or not password or not 1 <= port <= 65535:
        raise ValueError("SMTP configuration is incomplete.")
    marker = Path(str(args.preview) + ".attempted.jsonl")
    with marker.open("x", encoding="utf-8") as handle:
        handle.write(json.dumps({"state": "ATTEMPT_RESERVED", "sha256": args.sha256}) + "\n")
        handle.flush()
        os.fsync(handle.fileno())
    accepted = False
    try:
        with smtp_factory(host, port, timeout=20) as server:
            server.ehlo()
            server.starttls(context=ssl.create_default_context())
            server.ehlo()
            server.login(user, password)
            refused = server.send_message(msg, from_addr=sender, to_addrs=[recipient])
            if refused:
                raise RuntimeError("Recipient refused.")
            accepted = True
    except (OSError, smtplib.SMTPException, RuntimeError) as exc:
        state = "SMTP_ACCEPTED_CLOSE_ERROR" if accepted else "UNKNOWN_OR_NOT_ACCEPTED"
        with marker.open("a", encoding="utf-8") as handle:
            handle.write(json.dumps({"state": state, "error_type": type(exc).__name__}) + "\n")
        raise RuntimeError(state + "; inspect provider logs before any retry.") from None
    with marker.open("a", encoding="utf-8") as handle:
        handle.write(json.dumps({"state": "SMTP_ACCEPTED_NOT_DELIVERY_PROOF"}) + "\n")
    print("SMTP_ACCEPTED_NOT_DELIVERY_PROOF")

def main():
    parser = argparse.ArgumentParser()
    commands = parser.add_subparsers(dest="command", required=True)
    p = commands.add_parser("prepare")
    p.add_argument("--file", required=True)
    p.add_argument("--sender", default="sender@example.com")
    p.add_argument("--to", default="reader@example.com")
    p.add_argument("--out", default="preview.eml")
    s = commands.add_parser("send")
    s.add_argument("preview")
    s.add_argument("--sha256", required=True)
    s.add_argument("--confirm-to", required=True)
    args = parser.parse_args()
    try:
        (prepare if args.command == "prepare" else send)(args)
    except (OSError, ValueError, RuntimeError, KeyError) as exc:
        print("STOP:", type(exc).__name__, str(exc) if isinstance(exc, (ValueError, RuntimeError)) else "Check local inputs/configuration.")
        raise SystemExit(1)

if __name__ == "__main__":
    main()

오프라인 실행과 예상 출력

python3 mail_report.py prepare --file report.csv
python3 -m unittest -v test_example.py

첫 명령은 preview.eml을 생성하고 mode=PREVIEW_ONLY, attachments=1, attachment_bytes=39, smtp_connections=0을 출력합니다. 첨부 SHA-256은 03a7c6ebfa709f0a63f02be4faf228013f8dd23ec9eb35fee0e90c50a31900eb입니다. 날짜·Message-ID·MIME 경계는 달라질 수 있어 새 EML의 해시가 ZIP 안 비교 파일과 같아야 하는 것은 아닙니다. 실행에서 출력된 해시는 방금 저장한 미리보기를 식별합니다.

실제 Python 검산에서는 기존 7개와 도메인 경계 3개, 총 10개 테스트가 통과했습니다. 반복 subtest는 별도 테스트 개수로 합산하지 않았습니다. 발신·수신 주소의 example.com·example.net·example.org 루트와 점으로 연결된 하위도메인을 차단하고, notexample.com처럼 경계가 다른 이름은 이 규칙으로 차단하지 않습니다. 가짜 전송기는 연결 인수와 호출 순서, 첨부 바이트를 기록했습니다. 실제 DNS 조회·소켓 연결·로그인·발송은 실행하지 않았으며 서버 인증 방식이나 배달 성공을 검증한 결과가 아닙니다.

실패 분기: 재시도 전에 무엇을 확인할까

조건 요청 전후 구분 처리
해시 또는 수신자 불일치 전송기 호출 전 새 미리보기를 다시 확인
기존 attempted.jsonl 존재 전송기 호출 전 이전 시도 내역을 먼저 조사
send_message에서 시간 초과 접수 여부 불명확 공급자 로그에서 중복 가능성 확인
접수 후 연결 종료 실패 접수는 반환됨 접수 후 종료 오류로 별도 기록

해시 불일치 오류의 복구는 보관된 EML을 수정하는 것이 아니라 원본 CSV를 확인해 새 미리보기를 생성하는 순서입니다. 수신자 오류라면 prepare의 주소 입력을 수정하고 다시 확인합니다. 이때 실제 발송 시도 기록이 있는 원본 EML은 그대로 보관합니다.

시도 기록은 미리보기 경로에 묶여 있습니다. 파일을 다른 이름으로 복사하면 새로운 경로가 되므로 전역 중복 방지 장치가 아닙니다. 기록 파일을 지우거나 이름을 바꿔 실패를 무시하면 중복 발송할 수 있습니다. SMTP_ACCEPTED_NOT_DELIVERY_PROOF는 서버 접수를 뜻하는 코드 경로의 이름이며 받은편지함 도착을 보장하지 않습니다.

실제 서비스 연결 전 남은 작업

이 예제의 send는 STARTTLS 방식을 전제로 하며 SMTP_SSL이나 OAuth 흐름은 구현하지 않았습니다. 공급자별 포트·인증 정책·인증서 설정은 별도로 확인해야 합니다. 자격증명은 소스나 ZIP에 넣지 않고 환경 변수로만 받습니다. 실서비스 발송은 별도 승인과 실제 공급자 환경 점검이 필요한 단계이며, 이 글의 검산 범위에는 포함하지 않았습니다.

실행 순서 정리

  1. smtplib 첨부 메일의 빈 실습 폴더를 선택하고 report.csv와 mail_report.py를 저장합니다. 실제 주소나 비밀번호는 넣지 않습니다.

  2. prepare 명령만 실행해 새 preview.eml과 그 실행의 해시를 확인합니다.

  3. test_example.py를 실행해 가짜 전송기의 순서와 중복 시도 차단을 확인하고 실제 send 명령은 이 검산에서 실행하지 않습니다.

예제 흐름 그림

EML 파일 생성, 가짜 전송기의 SMTP 요청 순서, 검증하지 않은 받은편지함 도착을 구분하는 설명도
설명용 도해: 이 실습은 preview.eml의 첨부 1개와 원본 39바이트 복원, 가짜 전송기의 EHLO→STARTTLS→EHLO→LOGIN→SEND 순서를 확인합니다. 실제 SMTP 접속·발송이나 받은편지함 도착을 검증한 결과가 아닙니다.
가상 report.csv의 A·B 두 행을 EML에 첨부하고 복원한 39바이트를 원본과 비교하는 흐름도
설명용 도해: 가상 CSV는 A의 수량 2·금액 2400, B의 수량 3·금액 4500이며 줄바꿈까지 39바이트입니다. 수량 합계 5, 금액 합계 6900과 파일명 report.csv, 첨부 1개를 확인합니다. 바이트 일치는 업무 내용의 정확성 판단과 다릅니다.
EML 해시·확인 수신자·첨부 1개·기존 시도 기록을 확인하고 불일치나 중복 시도를 중단하는 체크리스트
설명용 도해: 방금 생성한 EML의 해시, 확인한 수신자, CSV/XLSX 첨부 1개와 원본 바이트를 검토합니다. 첨부 제한은 prepare에서 적용하는 2,000,000바이트입니다. 해당 미리보기의 attempted.jsonl 기록이 있으면 전송기 호출 전에 중단하며, 새 EML은 날짜·Message-ID·MIME 경계 때문에 해시가 달라질 수 있습니다.
불일치·기존 시도 기록·전송 중 시간 초과·접수 후 종료 오류의 서로 다른 상태와 후속 확인을 정리한 설명도
설명용 도해: 해시·수신자 불일치와 기존 기록은 전송기 호출 전에 중단합니다. 시간 초과는 접수 여부가 불명확하고, 접수 뒤 종료 오류는 별도 상태로 남깁니다. 시도 기록은 미리보기 경로별이므로 파일명 변경이나 기록 삭제로 우회하면 중복 발송할 수 있습니다.
복사본과 README 확인 후 prepare로 새 EML을 만들고 기본 7개와 경계 3개, 총 10개 검사를 수행하는 로컬 실습 순서
설명용 도해: README를 먼저 읽고 가상 입력의 복사본에서 prepare와 test_example.py를 실행합니다. 검사는 기존 7개와 도메인 경계 3개, 총 10개이며 반복 subtest는 별도 검사 개수로 합산하지 않습니다. 전송 동작은 메모리 가짜 전송기로 확인하며 실제 send 명령·SMTP 접속·발송은 검산에 포함하지 않습니다. 실서비스 연결에는 별도 승인 및 공급자 포트·인증 정책 점검이 필요하고 SMTP_SSL·OAuth는 이 예제에 구현되지 않았습니다.

공식 자료

smtplib — SMTP protocol client

email.message: Representing an email message

출처 원문 수집일: S1 2026-09-24; S2 2026-09-24.

작성자 소개