From 1ecb74f9f6f1eeb1f082bda8dfd8733dc7f0fde7 Mon Sep 17 00:00:00 2001 From: dreamhunter2333 Date: Mon, 29 Apr 2024 21:46:37 +0800 Subject: [PATCH] feat: add SMTP proxy server --- .github/workflows/smtp_proxy_server.yml | 40 +++++ CHANGELOG.md | 1 + README.md | 10 +- smtp_proxy_server/.env.example | 2 + smtp_proxy_server/.gitignore | 161 ++++++++++++++++++ smtp_proxy_server/docker-compose.yaml | 12 ++ smtp_proxy_server/dockerfile | 7 + smtp_proxy_server/requirements.txt | 3 + smtp_proxy_server/server.py | 146 ++++++++++++++++ vitepress-docs/docs/.vitepress/zh.ts | 9 + .../docs/zh/guide/config-smtp-proxy.md | 41 +++++ .../docs/zh/guide/feature/send-mail-api.md | 49 ++++++ .../docs/zh/guide/feature/subdomain.md | 5 + 13 files changed, 482 insertions(+), 4 deletions(-) create mode 100644 .github/workflows/smtp_proxy_server.yml create mode 100644 smtp_proxy_server/.env.example create mode 100644 smtp_proxy_server/.gitignore create mode 100644 smtp_proxy_server/docker-compose.yaml create mode 100644 smtp_proxy_server/dockerfile create mode 100644 smtp_proxy_server/requirements.txt create mode 100644 smtp_proxy_server/server.py create mode 100644 vitepress-docs/docs/zh/guide/config-smtp-proxy.md create mode 100644 vitepress-docs/docs/zh/guide/feature/send-mail-api.md create mode 100644 vitepress-docs/docs/zh/guide/feature/subdomain.md diff --git a/.github/workflows/smtp_proxy_server.yml b/.github/workflows/smtp_proxy_server.yml new file mode 100644 index 000000000..65ca24b7d --- /dev/null +++ b/.github/workflows/smtp_proxy_server.yml @@ -0,0 +1,40 @@ +name: SMTP Proxy Server Docker Image CI + +on: + push: + paths: + - "smtp_proxy_server/**" + tags: + - "*" + branches: + - main + workflow_dispatch: + +env: + REGISTRY: ghcr.io + IMAGE_NAME: smtp_proxy_server + +jobs: + build-and-push-image: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Log in to the Container registry + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Build and push Docker images + uses: docker/build-push-action@v5 + with: + context: . + push: true + tags: | + ${{ env.REGISTRY }}/${{ github.repository }}/${{ env.IMAGE_NAME }}:${{ github.ref_name }} + ${{ env.REGISTRY }}/${{ github.repository }}/${{ env.IMAGE_NAME }}:latest diff --git a/CHANGELOG.md b/CHANGELOG.md index 955ec254f..2baeb2c5e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ - `ENABLE_USER_CREATE_EMAIL` 是否允许用户创建邮件 - 允许 admin 创建无前缀的邮件 +- 添加 `SMTP proxy server`,支持 SMTP 发送邮件 ## v0.3.0 diff --git a/README.md b/README.md index c0322684d..a364b54eb 100644 --- a/README.md +++ b/README.md @@ -32,12 +32,14 @@ - [x] 使用 Cloudflare Pages 部署前端 - [x] 使用 Cloudflare Workers 部署后端 - [x] email 转发使用 Cloudflare Email Routing -- [x] 使用 password 重新登录之前的邮箱 +- [x] 使用 `password` 重新登录之前的邮箱 - [x] 获取自定义名字的邮箱 - [x] 支持多语言 - [x] 增加访问密码,可作为私人站点 - [x] 增加自动回复功能 -- [x] 增加查看附件功能 -- [x] 使用 rust wasm 解析邮件 +- [x] 增加查看 `附件` 功能 +- [x] 使用 `rust wasm` 解析邮件 - [x] 支持发送邮件 -- [x] 支持 DKIM +- [x] 支持 `DKIM` +- [x] `admin` 后台创建无前缀邮箱 +- [x] 添加 `SMTP proxy server`,支持 SMTP 发送邮件 diff --git a/smtp_proxy_server/.env.example b/smtp_proxy_server/.env.example new file mode 100644 index 000000000..b683f389b --- /dev/null +++ b/smtp_proxy_server/.env.example @@ -0,0 +1,2 @@ +proxy_url=https://temp-email-api.xxx.xxx +port=8025 diff --git a/smtp_proxy_server/.gitignore b/smtp_proxy_server/.gitignore new file mode 100644 index 000000000..c7cdacebf --- /dev/null +++ b/smtp_proxy_server/.gitignore @@ -0,0 +1,161 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +#poetry.lock + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +#pdm.lock +# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it +# in version control. +# https://pdm.fming.dev/#use-with-ide +.pdm.toml + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +#.idea/ +test* diff --git a/smtp_proxy_server/docker-compose.yaml b/smtp_proxy_server/docker-compose.yaml new file mode 100644 index 000000000..5227bf51f --- /dev/null +++ b/smtp_proxy_server/docker-compose.yaml @@ -0,0 +1,12 @@ +services: + smtp_proxy_server: + image: ghcr.io/dreamhunter2333/cloudflare_temp_email/smtp_proxy_server:latest + # build: + # context: . + # dockerfile: dockerfile + container_name: "smtp_proxy_server" + ports: + - "8025:8025" + environment: + - proxy_url=https://temp-email-api.xxx.xxx + - port=8025 diff --git a/smtp_proxy_server/dockerfile b/smtp_proxy_server/dockerfile new file mode 100644 index 000000000..c842706da --- /dev/null +++ b/smtp_proxy_server/dockerfile @@ -0,0 +1,7 @@ +FROM python:3.12-slim + +WORKDIR /app +COPY requirements.txt /requirements.txt +RUN python3 -m pip install -r /requirements.txt +COPY . /app +ENTRYPOINT [ "python3", "server.py" ] diff --git a/smtp_proxy_server/requirements.txt b/smtp_proxy_server/requirements.txt new file mode 100644 index 000000000..740247a89 --- /dev/null +++ b/smtp_proxy_server/requirements.txt @@ -0,0 +1,3 @@ +aiosmtpd==1.4.5 +pydantic-settings==2.2.1 +requests==2.31.0 diff --git a/smtp_proxy_server/server.py b/smtp_proxy_server/server.py new file mode 100644 index 000000000..0267da8d8 --- /dev/null +++ b/smtp_proxy_server/server.py @@ -0,0 +1,146 @@ +import asyncio +import logging +import email +import requests + +from pydantic_settings import BaseSettings +from aiosmtpd.controller import Controller +from aiosmtpd.smtp import SMTP, Session, Envelope, AuthResult, LoginPassword + +logging.basicConfig( + format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', +) + +_logger = logging.getLogger(__name__) +_logger.setLevel(logging.INFO) + + +class Settings(BaseSettings): + proxy_url: str = "http://localhost:8787" + port: int = 8025 + + class Config: + env_file = ".env" + + +class CustomSMTPHandler: + + def authenticator(self, server, session, envelope, mechanism, auth_data): + fail_nothandled = AuthResult(success=False, handled=False) + if mechanism not in ("LOGIN", "PLAIN"): + _logger.warning(f"Unsupported mechanism {mechanism}") + return fail_nothandled + if not isinstance(auth_data, LoginPassword): + _logger.warning(f"Invalid auth data {auth_data}") + return fail_nothandled + return AuthResult(success=True, auth_data=auth_data) + + async def handle_DATA(self, server: SMTP, session: Session, envelope: Envelope) -> str: + _logger.info( + f"handle_DATA from {envelope.mail_from} to {envelope.rcpt_tos}" + ) + if not isinstance(session.auth_data, LoginPassword): + return '530 Authentication required' + if len(envelope.rcpt_tos) != 1: + return '500 Only one recipient allowed' + # Only one recipient allowed + to_mail = envelope.rcpt_tos[0] + # Parse email + msg = email.message_from_string(envelope.content) + content_list = [] + if msg.is_multipart(): + for part in msg.walk(): + content_type = part.get_content_type() + payload = part.get_payload(decode=True) + if content_type not in ["text/plain", "text/html"]: + _logger.warning(f"Skipping {content_type}") + continue + if not payload: + continue + content_list.append({ + "type": content_type, + "value": payload.decode() + }) + elif msg.get_content_type() in ["text/plain", "text/html"] and msg.get_payload(decode=True): + content_list.append({ + "type": msg.get_content_type(), + "value": msg.get_payload(decode=True).decode() + }) + + if not content_list: + return '500 Invalid content' + body = max( + content_list, + key=lambda x: (x["type"] == "text/html", len(x["value"])) + ) + from_name, _ = email.utils.parseaddr( + str(email.header.make_header( + email.header.decode_header(msg['From']) + )) + ) + to_mail_map = {} + for to in str(email.header.make_header( + email.header.decode_header(msg['To']) + )).split(","): + tmp_to_name, tmp_to_mail = email.utils.parseaddr(to) + to_mail_map[tmp_to_mail] = tmp_to_name + _logger.info(f"Parsed mail from {from_name} to {to_mail_map}") + # Send mail + send_body = { + "from_name": from_name, + "to_name": to_mail_map.get(to_mail), + "to_mail": to_mail, + "subject": str(email.header.make_header( + email.header.decode_header(msg['Subject']) + )), + "is_html": body["type"] == "text/html", + "content": body["value"], + } + _logger.info(f"Send mail {send_body}") + try: + res = requests.post( + f"{settings.proxy_url}/api/send_mail", + json=send_body, headers={ + "Authorization": f"Bearer {session.auth_data.password.decode()}", + "Content-Type": "application/json" + } + ) + if res.status_code != 200: + _logger.error( + "Failed to send mail " + f"code=[{res.status_code}] text=[{res.text}]" + ) + return f'500 Internal server error code=[{res.status_code}] text=[{res.text}]' + except Exception as e: + _logger.error(e) + return '500 Internal server error' + + return '250 OK' + + +settings = Settings() +handler = CustomSMTPHandler() +server = Controller( + handler, + port=settings.port, + auth_require_tls=False, + decode_data=True, + authenticator=handler.authenticator, + auth_exclude_mechanism=["DONT"] +) + + +async def start(): + _logger.info(f"Starting server settings[{settings}]") + server.start() + + +if __name__ == "__main__": + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + task = loop.create_task(start()) + try: + loop.run_forever() + except KeyboardInterrupt: + _logger.info("Got KeyboardInterrupt, stopping") + server.stop() diff --git a/vitepress-docs/docs/.vitepress/zh.ts b/vitepress-docs/docs/.vitepress/zh.ts index cb8687907..e7306ee22 100644 --- a/vitepress-docs/docs/.vitepress/zh.ts +++ b/vitepress-docs/docs/.vitepress/zh.ts @@ -126,6 +126,15 @@ function sidebarGuide(): DefaultTheme.SidebarItem[] { { text: '开发中', link: 'github-action' }, ] }, + { + text: '附加功能', + collapsed: false, + items: [ + { text: '配置 SMTP 代理服务', link: 'config-smtp-proxy' }, + { text: '发送邮件 API', link: 'feature/send-mail-api' }, + { text: '配置子域名邮箱', link: 'feature/subdomain' }, + ] + }, { text: '功能简介', collapsed: false, diff --git a/vitepress-docs/docs/zh/guide/config-smtp-proxy.md b/vitepress-docs/docs/zh/guide/config-smtp-proxy.md new file mode 100644 index 000000000..67e8be121 --- /dev/null +++ b/vitepress-docs/docs/zh/guide/config-smtp-proxy.md @@ -0,0 +1,41 @@ +# 搭建 SMTP 代理服务 + +## 为什么需要 SMTP 代理服务 + +SMTP 的应用场景更加广泛 + +## 如何搭建 SMTP 代理服务 + +### Local Run + +```bash +cd smtp_proxy_server/ +# 复制配置文件, 并修改配置文件 +# 你的 worker 地址,proxy_url=https://temp-email-api.xxx.xxx +# 你的 SMTP 服务端口,port=8025 +cp .env.example .env +python3 -m venv venv +./venv/bin/python3 -m pip install -r requirements.txt +./venv/bin/python3 server.py +``` + +### Docker Run + +```bash +cd smtp_proxy_server/ +docker-compose up -d +``` + +修改 docker-compose.yaml 中的环境变量, 注意选择合适的 `tag` + +```yaml +services: + smtp_proxy_server: + image: ghcr.io/dreamhunter2333/cloudflare_temp_email/smtp_proxy_server:latest + container_name: "smtp_proxy_server" + ports: + - "8025:8025" + environment: + - proxy_url=https://temp-email-api.xxx.xxx + - port=8025 +``` diff --git a/vitepress-docs/docs/zh/guide/feature/send-mail-api.md b/vitepress-docs/docs/zh/guide/feature/send-mail-api.md new file mode 100644 index 000000000..b3dc57d84 --- /dev/null +++ b/vitepress-docs/docs/zh/guide/feature/send-mail-api.md @@ -0,0 +1,49 @@ +# 发送邮件 API + +## 通过 HTTP API 发送邮件 + +这是一个 `python` 的例子,使用 `requests` 库发送邮件。 + +```python +send_body = { + "from_name": "发件人名字", + "to_name": "收件人名字", + "to_mail": "收件人地址", + "subject": "邮件主题", + "is_html": False, # 根据内容设置是否为 HTML + "content": "<邮件内容:html 或者 文本>", +} + +res = requests.post( + "http://localhost:8787/api/send_mail", + json=send_body, headers={ + "Authorization": f"Bearer {session.auth_data.password.decode()}", + "Content-Type": "application/json" + } +) +``` + +## 通过 SMTP 发送邮件 + +请先参考 [配置 SMTP 代理](/zh/guide/config-smtp-proxy.html)。 + +这是一个 `python` 的例子,使用 `smtplib` 库发送邮件。 + +`JWT令牌密码`: 即为邮箱登录密码,可以在 UI 界面中查看密码菜单中查看。 + +```python +import smtplib + +from email.mime.text import MIMEText +from email.mime.multipart import MIMEMultipart + + +with smtplib.SMTP('localhost', 8025) as smtp: + smtp.login("jwt", "此处填写你的JWT令牌密码") + message = MIMEMultipart() + message['From'] = "Me " + message['To'] = "Admin " + message['Subject'] = "测试主题" + message.attach(MIMEText("测试内容", 'html')) + smtp.sendmail("me@awsl.uk", "admin@awsl.uk", message.as_string()) +``` diff --git a/vitepress-docs/docs/zh/guide/feature/subdomain.md b/vitepress-docs/docs/zh/guide/feature/subdomain.md new file mode 100644 index 000000000..f8fb7acf9 --- /dev/null +++ b/vitepress-docs/docs/zh/guide/feature/subdomain.md @@ -0,0 +1,5 @@ +# 配置子域名邮箱 + +参考 + +- [配置子域名邮箱](https://github.com/dreamhunter2333/cloudflare_temp_email/issues/164#issuecomment-2082612710)