diff --git a/CHANGELOG.md b/CHANGELOG.md index dd093a7c..e88d35d1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # CHANGE LOG +## main branch + +- 用户名限制最长30个字符 +- 修复 `/external/api/send_mail` 未返回的 bug (#222) +- 添加 `IMAP proxy` 服务,支持 `IMAP` 查看邮件 + ## v0.4.0 ### DB Changes/Breaking changes diff --git a/README.md b/README.md index 69f4a791..ddbdeeca 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,20 @@ # 使用 cloudflare 免费服务,搭建临时邮箱 +
+ > 本项目仅供学习和个人用途,请勿将其用于任何违法行为,否则后果自负。 ## [查看部署文档](https://temp-mail-docs.awsl.uk) @@ -31,6 +46,7 @@ - [在线演示](#在线演示) - [功能/TODO](#功能todo) - [Reference](#reference) + - [Join Community](#join-community) ## 功能/TODO @@ -44,7 +60,7 @@ - [x] 支持发送邮件 - [x] 支持 `DKIM` - [x] `admin` 后台创建无前缀邮箱 -- [x] 添加 `SMTP proxy server`,支持 SMTP 发送邮件 +- [x] 添加 `SMTP proxy server`,支持 `SMTP` 发送邮件, `IMAP` 查看邮件 - [x] 添加完整的用户注册登录功能,可绑定邮箱地址,绑定后可自动获取邮箱JWT凭证切换不同邮箱 ## Reference @@ -53,3 +69,7 @@ - 使用 Cloudflare Pages 部署前端 - 使用 Cloudflare Workers 部署后端 - email 转发使用 Cloudflare Email Routing + +## Join Community + +- [Discord](https://discord.gg/dQEwTWhA6Q) diff --git a/smtp_proxy_server/config.py b/smtp_proxy_server/config.py new file mode 100644 index 00000000..335a913f --- /dev/null +++ b/smtp_proxy_server/config.py @@ -0,0 +1,21 @@ +import logging +from pydantic_settings import BaseSettings + +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 + imap_port: int = 11143 + + class Config: + env_file = ".env" + + +settings = Settings() diff --git a/smtp_proxy_server/docker-compose.yaml b/smtp_proxy_server/docker-compose.yaml index 5227bf51..4b753cd3 100644 --- a/smtp_proxy_server/docker-compose.yaml +++ b/smtp_proxy_server/docker-compose.yaml @@ -7,6 +7,8 @@ services: container_name: "smtp_proxy_server" ports: - "8025:8025" + - "11143:11143" environment: - proxy_url=https://temp-email-api.xxx.xxx - port=8025 + - imap_port=11143 diff --git a/smtp_proxy_server/dockerfile b/smtp_proxy_server/dockerfile index c842706d..425490a6 100644 --- a/smtp_proxy_server/dockerfile +++ b/smtp_proxy_server/dockerfile @@ -4,4 +4,4 @@ WORKDIR /app COPY requirements.txt /requirements.txt RUN python3 -m pip install -r /requirements.txt COPY . /app -ENTRYPOINT [ "python3", "server.py" ] +ENTRYPOINT [ "python3", "main.py" ] diff --git a/smtp_proxy_server/imap_server.py b/smtp_proxy_server/imap_server.py new file mode 100644 index 00000000..186bae7c --- /dev/null +++ b/smtp_proxy_server/imap_server.py @@ -0,0 +1,199 @@ +import json +import logging +import requests + +from io import BytesIO +from twisted.mail import imap4 +from zope.interface import implementer +from twisted.cred.portal import Portal, IRealm +from twisted.internet import protocol, reactor, defer +from twisted.cred.checkers import ICredentialsChecker, IUsernamePassword + +from config import settings +from parse_email import parse_email +from models import EmailModel + +_logger = logging.getLogger(__name__) +_logger.setLevel(logging.INFO) + + +@implementer(imap4.IMessage) +class SimpleMessage: + + def __init__(self, uid=None, email_model: EmailModel = None): + self.uid = uid + self.email = email_model + self.subparts = self.email.subparts + + def getUID(self): + return self.uid + + def getHeaders(self, negate, *names): + self.got_headers = negate, names + return { + k.lower(): v + for k, v in self.email.headers.items() + } + + def isMultipart(self): + return len(self.subparts) > 0 + + def getSubPart(self, part): + self.got_subpart = part + return SimpleMessage(email_model=self.subparts[part]) + + def getBodyFile(self): + return BytesIO(self.email.body.encode("utf-8")) + + def getSize(self): + return self.email.size + + def getFlags(self): + return ["\\Seen"] + + +@implementer(imap4.IMailboxInfo, imap4.IMailbox) +class SimpleMailbox: + + def __init__(self, password): + self.password = password + self.listeners = [] + self.addListener = self.listeners.append + self.removeListener = self.listeners.remove + self.message_count = 0 + + def getFlags(self): + return ["\\Seen"] + + def getUIDValidity(self): + return 0 + + def getMessageCount(self): + return 2 ** 31 - 1 + + def getRecentCount(self): + return 0 + + def getUnseenCount(self): + return 0 + + def isWriteable(self): + return 0 + + def destroy(self): + pass + + def getHierarchicalDelimiter(self): + return "/" + + def requestStatus(self, names): + r = {} + if "MESSAGES" in names: + r["MESSAGES"] = self.getMessageCount() + if "RECENT" in names: + r["RECENT"] = self.getRecentCount() + if "UIDNEXT" in names: + r["UIDNEXT"] = self.getMessageCount() + 1 + if "UIDVALIDITY" in names: + r["UIDVALIDITY"] = self.getUID() + if "UNSEEN" in names: + r["UNSEEN"] = self.getUnseenCount() + return defer.succeed(r) + + def fetch(self, messages, uid): + start, end = messages.ranges[0] + start = max(start, 1) + if start > self.message_count: + return [] + res = requests.get( + f"{settings.proxy_url}/api/mails?limit=20&offset={start - 1}", headers={ + "Authorization": f"Bearer {self.password}", + "Content-Type": "application/json" + } + ) + if res.status_code != 200: + _logger.error( + "Failed: " + f"code=[{res.status_code}] text=[{res.text}]" + ) + raise Exception("Failed to fetch emails") + if res.json()["count"] > 0: + self.message_count = res.json()["count"] + return [ + (start + uid, SimpleMessage(start + uid, parse_email(item["raw"]))) + for uid, item in enumerate(reversed(res.json()["results"])) + ] + + def getUID(self, message): + return message.uid + + def store(self, messages, flags, mode, uid): + # IMailboxIMAP.store + pass + + +class Account(imap4.MemoryAccount): + + def __init__(self, user, password): + self.password = password + super().__init__(user) + + def _emptyMailbox(self, name, id): + _logger.info(f"New mailbox: {name}, {id}") + if name != "INBOX": + raise imap4.NoSuchMailbox(name) + return SimpleMailbox(self.password) + + def select(self, name, rw=1): + return imap4.MemoryAccount.select(self, name) + + +class SimpleIMAPServer(imap4.IMAP4Server): + def __init__(self, factory): + imap4.IMAP4Server.__init__(self) + self.factory = factory + + def lineReceived(self, line): + super().lineReceived(line) + + +@implementer(IRealm) +class SimpleRealm: + def requestAvatar(self, avatarId, mind, *interfaces): + res = json.loads(avatarId) + account = Account(res["username"], res["password"]) + account.addMailbox("INBOX") + return imap4.IAccount, account, lambda: None + + +class IMAPFactory(protocol.Factory): + def __init__(self, portal): + self.portal = portal + + def buildProtocol(self, addr): + p = SimpleIMAPServer(self) + p.portal = self.portal + return p + + +@implementer(ICredentialsChecker) +class CustomChecker: + credentialInterfaces = (IUsernamePassword,) + + def requestAvatarId(self, credentials): + return defer.succeed(json.dumps({ + "username": credentials.username.decode(), + "password": credentials.password.decode(), + })) + + +def start_imap_server(): + _logger.info(f"Starting IMAP server on port {settings.imap_port}") + portal = Portal(SimpleRealm(), [CustomChecker()]) + reactor.listenTCP(settings.imap_port, IMAPFactory(portal)) + reactor.run() + + +if __name__ == "__main__": + _logger.info(f"Starting server settings[{settings}]") + start_imap_server() diff --git a/smtp_proxy_server/main.py b/smtp_proxy_server/main.py new file mode 100644 index 00000000..5f587fc6 --- /dev/null +++ b/smtp_proxy_server/main.py @@ -0,0 +1,24 @@ +import logging +import multiprocessing + +from smtp_server import start_smtp_server +from imap_server import start_imap_server +from config import settings + +_logger = logging.getLogger(__name__) +_logger.setLevel(logging.INFO) + +if __name__ == '__main__': + _logger.info(f"Starting server settings[{settings}]") + process_list = [ + multiprocessing.Process(target=start_smtp_server, args=()), + multiprocessing.Process(target=start_imap_server, args=()), + ] + try: + for p in process_list: + p.start() + for p in process_list: + p.join() + except KeyboardInterrupt: + for p in process_list: + p.terminate() diff --git a/smtp_proxy_server/models.py b/smtp_proxy_server/models.py new file mode 100644 index 00000000..4791e5e9 --- /dev/null +++ b/smtp_proxy_server/models.py @@ -0,0 +1,10 @@ +from typing import Dict, List +from pydantic import BaseModel + + +class EmailModel(BaseModel): + headers: Dict[str, str] + body: str + content_type: str + subparts: List["EmailModel"] + size: int diff --git a/smtp_proxy_server/parse_email.py b/smtp_proxy_server/parse_email.py new file mode 100644 index 00000000..8e4116c6 --- /dev/null +++ b/smtp_proxy_server/parse_email.py @@ -0,0 +1,38 @@ +import email +from email.message import Message +import logging + +from models import EmailModel + +_logger = logging.getLogger(__name__) +_logger.setLevel(logging.INFO) + + +def get_email_model(msg: Message): + subparts = [ + get_email_model(subpart) + for subpart in msg.get_payload() + ] if msg.is_multipart() else [] + body = "" if msg.is_multipart() else msg._payload + return EmailModel( + headers={k: v for k, v in msg.items()}, + body=body, + content_type=msg.get_content_type(), + size=len(body) + sum(subpart.size for subpart in subparts), + subparts=subparts, + ) + + +def parse_email(raw: str) -> EmailModel: + try: + msg = email.message_from_string(raw) + return get_email_model(msg) + except Exception as e: + _logger.error(f"Could not parse email: {e}") + return EmailModel( + headers={}, + body="could not parse email", + content_type="text/plain", + size=len("could not parse email"), + subparts=[], + ) diff --git a/smtp_proxy_server/requirements.txt b/smtp_proxy_server/requirements.txt index 740247a8..d6d9481a 100644 --- a/smtp_proxy_server/requirements.txt +++ b/smtp_proxy_server/requirements.txt @@ -1,3 +1,4 @@ aiosmtpd==1.4.5 pydantic-settings==2.2.1 requests==2.31.0 +twisted==24.3.0 diff --git a/smtp_proxy_server/server.py b/smtp_proxy_server/smtp_server.py similarity index 94% rename from smtp_proxy_server/server.py rename to smtp_proxy_server/smtp_server.py index 604b765b..8436ad46 100644 --- a/smtp_proxy_server/server.py +++ b/smtp_proxy_server/smtp_server.py @@ -3,26 +3,15 @@ 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', -) +from config import settings _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): @@ -119,7 +108,6 @@ class CustomSMTPHandler: return '250 OK' -settings = Settings() handler = CustomSMTPHandler() server = Controller( handler, @@ -133,11 +121,11 @@ server = Controller( async def start(): - _logger.info(f"Starting server settings[{settings}]") + _logger.info(f"Starting server on port {settings.port}") server.start() -if __name__ == "__main__": +def start_smtp_server(): loop = asyncio.new_event_loop() asyncio.set_event_loop(loop) task = loop.create_task(start()) @@ -146,3 +134,8 @@ if __name__ == "__main__": except KeyboardInterrupt: _logger.info("Got KeyboardInterrupt, stopping") server.stop() + + +if __name__ == "__main__": + _logger.info(f"Starting server settings[{settings}]") + start_smtp_server() diff --git a/vitepress-docs/docs/.vitepress/zh.ts b/vitepress-docs/docs/.vitepress/zh.ts index a54059c9..3a7b0c93 100644 --- a/vitepress-docs/docs/.vitepress/zh.ts +++ b/vitepress-docs/docs/.vitepress/zh.ts @@ -123,14 +123,14 @@ function sidebarGuide(): DefaultTheme.SidebarItem[] { text: '通过 Github Actions 部署', collapsed: false, items: [ - { text: '开发中', link: 'github-action' }, + { text: '通过 Github Actions 部署', link: 'github-action' }, ] }, { text: '附加功能', collapsed: false, items: [ - { text: '配置 SMTP 代理服务', link: 'feature/config-smtp-proxy' }, + { text: '配置 SMTP IMAP 代理服务', link: 'feature/config-smtp-proxy' }, { text: '发送邮件 API', link: 'feature/send-mail-api' }, { text: '查看邮件 API', link: 'feature/mail-api' }, { text: '配置子域名邮箱', link: 'feature/subdomain' }, diff --git a/vitepress-docs/docs/public/feature/imap.png b/vitepress-docs/docs/public/feature/imap.png new file mode 100644 index 00000000..d18c6d0f Binary files /dev/null and b/vitepress-docs/docs/public/feature/imap.png differ diff --git a/vitepress-docs/docs/zh/guide/feature/config-smtp-proxy.md b/vitepress-docs/docs/zh/guide/feature/config-smtp-proxy.md index 67e8be12..9f905e7c 100644 --- a/vitepress-docs/docs/zh/guide/feature/config-smtp-proxy.md +++ b/vitepress-docs/docs/zh/guide/feature/config-smtp-proxy.md @@ -1,10 +1,10 @@ -# 搭建 SMTP 代理服务 +# 搭建 SMTP IMAP 代理服务 -## 为什么需要 SMTP 代理服务 +## 为什么需要 SMTP IMAP 代理服务 -SMTP 的应用场景更加广泛 +`SMTP` `IMAP` 的应用场景更加广泛 -## 如何搭建 SMTP 代理服务 +## 如何搭建 SMTP IMAP 代理服务 ### Local Run @@ -16,7 +16,7 @@ cd smtp_proxy_server/ cp .env.example .env python3 -m venv venv ./venv/bin/python3 -m pip install -r requirements.txt -./venv/bin/python3 server.py +./venv/bin/python3 main.py ``` ### Docker Run @@ -32,10 +32,21 @@ docker-compose up -d 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" + - "11143:11143" environment: - proxy_url=https://temp-email-api.xxx.xxx - port=8025 + - imap_port=11143 ``` + +## 使用 Thunderbird 登录 + +下载 [Thunderbird](https://www.thunderbird.net/en-US/) + +