feat: add imap proxy server (#225)

This commit is contained in:
Dream Hunter
2024-05-12 11:34:52 +08:00
committed by GitHub
parent 386441a743
commit af027fd75e
14 changed files with 349 additions and 24 deletions

View File

@@ -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

View File

@@ -1,5 +1,20 @@
# 使用 cloudflare 免费服务,搭建临时邮箱
<p align="center">
<a href="https://github.com/dreamhunter2333/cloudflare_temp_email/blob/main/LICENSE">
<img alt="MIT License" src="https://img.shields.io/github/license/dreamhunter2333/cloudflare_temp_email">
</a>
<a href="https://github.com/dreamhunter2333/cloudflare_temp_email/graphs/contributors">
<img alt="GitHub contributors" src="https://img.shields.io/github/contributors/dreamhunter2333/cloudflare_temp_email">
</a>
<a href="">
<img alt="GitHub top language" src="https://img.shields.io/github/languages/top/dreamhunter2333/cloudflare_temp_email">
</a>
<a href="https://discord.gg/dQEwTWhA6Q">
<img alt="Join Discord Chat" src="https://img.shields.io/discord/1238705663623036939.svg?label=discord&logo=discord">
</a>
</p>
> 本项目仅供学习和个人用途,请勿将其用于任何违法行为,否则后果自负。
## [查看部署文档](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)

View File

@@ -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()

View File

@@ -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

View File

@@ -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" ]

View File

@@ -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()

24
smtp_proxy_server/main.py Normal file
View File

@@ -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()

View File

@@ -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

View File

@@ -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=[],
)

View File

@@ -1,3 +1,4 @@
aiosmtpd==1.4.5
pydantic-settings==2.2.1
requests==2.31.0
twisted==24.3.0

View File

@@ -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()

View File

@@ -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' },

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

View File

@@ -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/)
![imap](/feature/imap.png)