├── .gitignore ├── images ├── tg.png └── qqmail.png ├── .gitattributes ├── deploy ├── .env ├── acme.sh-docker.sh ├── compose.yml └── config.yml ├── .github └── workflows │ └── ghcr.yml ├── go.mod ├── check_port_25_connectivity.sh ├── config.go ├── readme.md ├── go.sum ├── main.go └── func.go /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | app-amd64-linux 3 | test.py 4 | -------------------------------------------------------------------------------- /images/tg.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yumusb/email_router/HEAD/images/tg.png -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto 3 | -------------------------------------------------------------------------------- /deploy/.env: -------------------------------------------------------------------------------- 1 | CF_Token= 2 | CF_Zone_ID= 3 | DNS_API=dns_cf 4 | ACME_SH_EMAIL= 5 | MXDOMAIN= 6 | -------------------------------------------------------------------------------- /images/qqmail.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yumusb/email_router/HEAD/images/qqmail.png -------------------------------------------------------------------------------- /deploy/acme.sh-docker.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | if [ ! -f /acme.sh/account.conf ]; then 3 | echo 'First startup' 4 | acme.sh --update-account --accountemail ${ACME_SH_EMAIL} 5 | echo 'Asking for certificates' 6 | acme.sh --issue -d "${DOMAIN}" --dns "${DNS_API}" --server letsencrypt --log 7 | fi 8 | if [ ! -f /cert/fullchain.pem ]; then 9 | acme.sh --install-cert -d "${DOMAIN}" --cert-file /cert/cert.pem --key-file /cert/key.pem --fullchain-file /cert/fullchain.pem 10 | fi 11 | echo 'Listing certs' 12 | acme.sh --upgrade --auto-upgrade 13 | acme.sh --list 14 | # Make the container keep running 15 | /entry.sh daemon -------------------------------------------------------------------------------- /.github/workflows/ghcr.yml: -------------------------------------------------------------------------------- 1 | name: "Build" 2 | on: 3 | push: 4 | branches: [ master ] 5 | paths: 6 | - '**/*.go' 7 | - 'go.*' 8 | - 'build/**' 9 | 10 | jobs: 11 | build-docker: 12 | name: "Build Docker" 13 | runs-on: "ubuntu-latest" 14 | permissions: 15 | contents: read 16 | packages: write 17 | steps: 18 | - uses: actions/checkout@v4 19 | name: Check out code 20 | - uses: mr-smithers-excellent/docker-build-push@v6 21 | name: Build & push Docker image 22 | with: 23 | image: email_router 24 | tags: latest 25 | addLatest: true 26 | registry: ghcr.io 27 | dockerfile: build/Dockerfile 28 | username: ${{ github.actor }} 29 | password: ${{ secrets.GITHUB_TOKEN }} 30 | -------------------------------------------------------------------------------- /deploy/compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | acme: 3 | image: neilpang/acme.sh 4 | volumes: 5 | - ./cert:/cert/ 6 | - ./acme.sh:/acme.sh 7 | - ./acme.sh-docker.sh:/acme.sh-docker.sh:ro 8 | entrypoint: 9 | restart: always 10 | environment: 11 | - CF_Token=${CF_Token} 12 | - CF_Zone_ID=${CF_Zone_ID} 13 | - DNS_API=${DNS_API} 14 | - ACME_SH_EMAIL=${ACME_SH_EMAIL} 15 | - DOMAIN=${MXDOMAIN} 16 | command: sh ./acme.sh-docker.sh 17 | healthcheck: 18 | test: ["CMD", "sh", "-c", "[ -f /cert/cert.pem ] && openssl x509 -checkend 86400 -noout -in /cert/cert.pem"] 19 | interval: 30s 20 | timeout: 10s 21 | retries: 3 22 | 23 | backend: 24 | image: ghcr.io/yumusb/email_router:latest 25 | volumes: 26 | - ./config.yml:/app/config.yml:ro 27 | - ./cert:/cert/ 28 | restart: always 29 | environment: 30 | - MXDOMAIN=${MXDOMAIN} 31 | ports: 32 | - "25:25" 33 | - "587:587" 34 | depends_on: 35 | acme: 36 | condition: service_healthy -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module email_router 2 | 3 | go 1.23.0 4 | 5 | toolchain go1.24.1 6 | 7 | require gopkg.in/yaml.v2 v2.4.0 8 | 9 | require ( 10 | blitiri.com.ar/go/spf v1.5.1 11 | github.com/google/uuid v1.6.0 12 | github.com/jhillyerd/enmime v1.3.0 13 | github.com/sirupsen/logrus v1.9.3 14 | github.com/toorop/go-dkim v0.0.0-20250226130143-9025cce95817 15 | github.com/yumusb/go-smtp v0.0.0-20241013125232-63ad8b4f888a 16 | ) 17 | 18 | require ( 19 | github.com/cention-sany/utf7 v0.0.0-20170124080048-26cad61bd60a // indirect 20 | github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21 // indirect 21 | github.com/gogs/chardet v0.0.0-20211120154057-b7413eaefb8f // indirect 22 | github.com/jaytaylor/html2text v0.0.0-20230321000545-74c2419ad056 // indirect 23 | github.com/mattn/go-runewidth v0.0.15 // indirect 24 | github.com/olekukonko/tablewriter v0.0.5 // indirect 25 | github.com/pkg/errors v0.9.1 // indirect 26 | github.com/rivo/uniseg v0.4.4 // indirect 27 | github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf // indirect 28 | golang.org/x/net v0.38.0 // indirect 29 | golang.org/x/sys v0.31.0 // indirect 30 | golang.org/x/text v0.23.0 // indirect 31 | ) 32 | -------------------------------------------------------------------------------- /deploy/config.yml: -------------------------------------------------------------------------------- 1 | telegram: 2 | bot_token: "" # Telegram bot的token 3 | chat_id: "" # 发送消息的chat ID 4 | send_eml: False # 是否通过Telegram发送邮件通知 5 | 6 | smtp: 7 | listen_address: "0.0.0.0:25" # SMTP服务器监听的地址 8 | listen_address_tls: "0.0.0.0:587" # TLS的监听地址 9 | allowed_domains: # 允许的域名列表 10 | - "domain.local" 11 | cert_file: "/cert/fullchain.pem" # TLS证书路径 12 | key_file: "/cert/key.pem" # TLS私钥路径 13 | private_email: "root@local" # 私有邮箱地址 14 | enable_dmarc: False # 是否启用DMARC 15 | dkim_private_key: | 16 | -----BEGIN PRIVATE KEY----- 17 | MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQCcO5W2T1MIne5v 18 | ....... 19 | -----END PRIVATE KEY----- 20 | dkim_selector: "dkim" # DKIM选择器 21 | webhook: 22 | enabled: False # 是否启用Webhook功能 23 | method: "POST" # HTTP方法,例如POST或GET 24 | url: "https://example.com/webhook" # Webhook目标URL 25 | headers: # 自定义Headers 26 | Authorization: "Bearer my-token" 27 | body: # 请求的Body数据,包含模板变量 28 | title: "新邮件: {{.Title}}" 29 | content: "详情: {{.Content}}" 30 | field1: "value1" 31 | bodyType: "json" # 请求体类型,可以是 "json" 或 "form" 32 | -------------------------------------------------------------------------------- /check_port_25_connectivity.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # 要检查的域名列表 4 | domains=("gmail.com" "yahoo.com" "outlook.com") 5 | 6 | # 定义颜色 7 | GREEN='\033[0;32m' 8 | RED='\033[0;31m' 9 | NC='\033[0m' # 无颜色 10 | 11 | # 检查必需的命令是否安装 12 | check_requirements() { 13 | local missing_commands=() 14 | 15 | # 检查 dig 命令 16 | if ! command -v dig &> /dev/null; then 17 | missing_commands+=("dig (dnsutils 或 bind-utils)") 18 | fi 19 | 20 | # 检查 nc 命令 21 | if ! command -v nc &> /dev/null; then 22 | missing_commands+=("nc (netcat)") 23 | fi 24 | 25 | # 如果有缺失的命令,显示安装建议 26 | if [ ${#missing_commands[@]} -ne 0 ]; then 27 | echo -e "${RED}错误: 以下命令未安装:${NC}" 28 | for cmd in "${missing_commands[@]}"; do 29 | echo " - $cmd" 30 | done 31 | echo -e "\n请使用包管理器安装缺失的包:" 32 | echo "Ubuntu/Debian: sudo apt install dnsutils netcat" 33 | echo "CentOS/RHEL: sudo yum install bind-utils nc" 34 | exit 1 35 | fi 36 | } 37 | 38 | # 检查每个域名的MX服务器是否能连接到25端口 39 | check_mx_port_25() { 40 | local domain=$1 41 | echo -e "Checking MX records for domain: $domain" 42 | 43 | # 查询MX记录 44 | mx_records=$(dig +short MX "$domain" | awk '{print $2}') 45 | 46 | if [[ -z "$mx_records" ]]; then 47 | echo -e "${RED}No MX records found for $domain${NC}" 48 | return 49 | fi 50 | 51 | for mx in $mx_records; do 52 | echo -e "Checking MX server $mx on port 25..." 53 | 54 | # 尝试连接MX服务器的25端口 55 | nc -z -w 5 "$mx" 25 56 | if [[ $? -eq 0 ]]; then 57 | echo -e "${GREEN}Success: Able to connect to $mx on port 25.${NC}" 58 | else 59 | echo -e "${RED}Failed: Unable to connect to $mx on port 25.${NC}" 60 | fi 61 | done 62 | } 63 | 64 | # 在主程序开始前调用检查 65 | check_requirements 66 | 67 | # 遍历所有域名进行检查 68 | for domain in "${domains[@]}"; do 69 | check_mx_port_25 "$domain" 70 | echo 71 | done -------------------------------------------------------------------------------- /config.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import "blitiri.com.ar/go/spf" 4 | 5 | var headersToRemove = []string{"x-*", "x-spam-*", "x-mailer", "x-originating-*", "x-qq-*", "dkim-*", "x-google-*", "x-cm-*", "x-coremail-*", "x-bq-*", "message-id"} 6 | var CONFIG Config 7 | 8 | const headerPrefix = "X-ROUTER-" 9 | const telegramMaxLength = 4096 10 | 11 | type Config struct { 12 | SMTP SMTPConfig `yaml:"smtp"` 13 | Telegram TelegramConfig `yaml:"telegram"` 14 | Webhook WebhookConfig `yaml:"webhook"` // 新增 Webhook 配置 15 | } 16 | 17 | type SMTPConfig struct { 18 | ListenAddress string `yaml:"listen_address"` 19 | ListenAddressTls string `yaml:"listen_address_tls"` 20 | AllowedDomains []string `yaml:"allowed_domains"` 21 | PrivateEmail string `yaml:"private_email"` 22 | CertFile string `yaml:"cert_file"` 23 | KeyFile string `yaml:"key_file"` 24 | EnableDMARC bool `yaml:"enable_dmarc"` 25 | DKIMPrivateKey string `yaml:"dkim_private_key"` 26 | DKIMSelector string `yaml:"dkim_selector"` 27 | } 28 | 29 | type TelegramConfig struct { 30 | BotToken string `yaml:"bot_token"` 31 | ChatID string `yaml:"chat_id"` 32 | SendEML bool `yaml:"send_eml"` 33 | } 34 | 35 | type WebhookConfig struct { 36 | Enabled bool `yaml:"enabled"` // 是否启用 Webhook 37 | Method string `yaml:"method"` // HTTP 请求方法 38 | URL string `yaml:"url"` // Webhook URL 39 | Headers map[string]string `yaml:"headers"` // 自定义 Headers 40 | Body map[string]string `yaml:"body"` // 请求体数据(支持模板变量) 41 | BodyType string `yaml:"bodyType"` // 请求体类型,可以是 "json" 或 "form" 42 | } 43 | 44 | type Backend struct { 45 | } 46 | type Session struct { 47 | from string 48 | to []string 49 | remoteIP string 50 | localIP string 51 | spfResult spf.Result 52 | remoteclientHostname string 53 | UUID string 54 | msgId string 55 | } 56 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # EMAIL ROUTER 2 | 3 | 该项目是一个支持 **多域名 Catch-All 邮件收件** 的工具,所有收到的邮件都不会存储,直接转发。它可以将邮件转发到 **Telegram 机器人** 和 **私人邮箱地址**。该工具的设计灵感来源于 [DuckDuckGo Email Protection](https://duckduckgo.com/duckduckgo-help-pages/email-protection/) ,目标是提供一个完全自托管的解决方案。 4 | 5 | ## 功能介绍 6 | 7 | - **Catch-All 邮件转发:** 支持将接收到的所有邮件直接转发到指定邮箱,而无需存储邮件内容。 8 | - **Telegram 机器人通知:** 收到的邮件可通过 Telegram 机器人进行实时通知。 9 | ![](./images/tg.png) 10 | - **多域名支持:** 可以跨多个域名进行邮件转发处理。 11 | - **SMTP 邮件发送:** 提供邮件发送功能,可以通过你定义的**私人邮箱地址**将邮件转发给指定的收件人。 12 | ![](./images/qqmail.png) 13 | 14 | ## 部署步骤 15 | 16 | 17 | ### 前置准备 18 | 19 | #### 1. 服务器准备 20 | 公网IP(以223.223.223.223为例),25端口可达 (可以通过本项目内的`check_port_25_connectivity.sh`进行测试) ,服务器安装好Docker,设置PTR记录(可选,如果只收信则不需要) 21 | #### 2. 域名准备 22 | 想要使用的域名(以404.local、403.local为例) 23 | 24 | 主域名需要设置MX服务器的A记录、MX记录、SPF记录。 25 | ```txt 26 | ;; A Records 27 | mx1.404.local. 1 IN A 223.223.223.223 28 | 29 | ;; MX Records 30 | 404.local. 1 IN MX 5 mx1.404.local. 31 | 32 | ;; TXT Records 33 | 404.local. 1 IN TXT "v=spf1 mx:404.local -all" 34 | ``` 35 | 其他域名需要设置MX记录,SPF记录跟随主域名 36 | ```txt 37 | ;; MX Records 38 | 403.local. 1 IN MX 5 mx1.404.local. 39 | 40 | ;; TXT Records 41 | 403.local. 1 IN TXT "v=spf1 include:404.local -all" 42 | ``` 43 | (不一定非要mx1前缀,任意都可) 44 | ### 1. 克隆仓库 45 | 46 | ```bash 47 | git clone https://github.com/yumusb/email_router.git 48 | cd email_router/deploy 49 | ``` 50 | 51 | ### 2. 证书相关配置 52 | 53 | 项目使用`acme.sh`提供证书,以保证收信过程中的安全。打开 .env 配置文件 54 | ```config 55 | DNS_API=dns_cf #目前指定了CF,后续可能完善逻辑 56 | ACME_SH_EMAIL= #随便一个邮箱,用来初始化acme账户 57 | MXDOMAIN= #mx服务器的域名,用来申请证书,按照本文中的例子就需要是mx1.404.local 58 | CF_Token= # 权限需要可以操作 404.local的DNS解析 59 | CF_Zone_ID= # 404.local的Zone ID 60 | ``` 61 | ### 3. config.yml配置 62 | ```yml 63 | telegram: 64 | bot_token: "<你的_bot_token>" 65 | chat_id: "<你的_chat_id>" 66 | 67 | smtp: 68 | listen_address: "0.0.0.0:25" 69 | listen_address_tls: "0.0.0.0:587" 70 | allowed_domains: 71 | - "404.local" 72 | - "403.local" 73 | cert_file: "/cert/fullchain.pem" 74 | key_file: "/cert/key.pem" 75 | private_email: "root123645@foxmail.com" 76 | ``` 77 | 需要修改的有 telegram相关,allowed_domains修改为自己的域名,private_email修改为要转发到的邮箱。 78 | ### 4. 启动 79 | ```shell 80 | docker compose up -d 81 | ``` 82 | 由于初次启动需要申请证书,所以需要一点时间来启动,只有有效的证书才能启动邮件服务。 83 | ### 5. 收件 84 | 为防止出现未知问题,程序对收件地址的规则做了限制。允许的收件地址规则为`^(\w|-)+@.+$` 85 | 例如以下: 86 | > root@404.local 87 | > 404@404.local 88 | > my-admin@404.local 89 | > random@404.local 90 | 91 | 可以结合`Bitwarden`中的用户名生成器,效果更佳。 92 | 可以通过自己的其他邮箱向域名邮箱进行发信测试,不出意外应该可以收到来自自己域名转发的邮件。 93 | 94 | ### 6. 发件 95 | 与[DuckDuckGo Email Protection](https://duckduckgo.com/duckduckgo-help-pages/email-protection/duck-addresses/how-do-i-compose-a-new-email/) 的逻辑一样 96 | > For example, if your personal Duck Address is jane@duck.com and you want to send to your friend’s email brian@gmail.com. To send the email from your personal Duck Address, you would send the message to brian_at_gmail.com_jane@duck.com. 97 | 98 | 其中 jane 可以是你喜欢的、你需要的 任意前缀,因为这是你的域名。 99 | 同样的,直接回复收到的转发的来信,服务器也会帮你自动转发回去。 100 | 101 | 需要注意的是,发信非常依赖信誉,你的IP信誉、域名信誉、PTR设置等。如果你发现私人邮箱收到的邮件在垃圾箱,可以手动加白下,很有可能发送给别人的也进了垃圾箱。 102 | 103 | 104 | ## 其他问题 105 | ### todo 106 | - [x] spf check 107 | - [ ] 证书自动续期相关逻辑 -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | blitiri.com.ar/go/spf v1.5.1 h1:CWUEasc44OrANJD8CzceRnRn1Jv0LttY68cYym2/pbE= 2 | blitiri.com.ar/go/spf v1.5.1/go.mod h1:E71N92TfL4+Yyd5lpKuE9CAF2pd4JrUq1xQfkTxoNdk= 3 | github.com/cention-sany/utf7 v0.0.0-20170124080048-26cad61bd60a h1:MISbI8sU/PSK/ztvmWKFcI7UGb5/HQT7B+i3a2myKgI= 4 | github.com/cention-sany/utf7 v0.0.0-20170124080048-26cad61bd60a/go.mod h1:2GxOXOlEPAMFPfp014mK1SWq8G8BN8o7/dfYqJrVGn8= 5 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 6 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 7 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 8 | github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21 h1:OJyUGMJTzHTd1XQp98QTaHernxMYzRaOasRir9hUlFQ= 9 | github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ= 10 | github.com/go-test/deep v1.1.0 h1:WOcxcdHcvdgThNXjw0t76K42FXTU7HpNQWHpA2HHNlg= 11 | github.com/go-test/deep v1.1.0/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= 12 | github.com/gogs/chardet v0.0.0-20211120154057-b7413eaefb8f h1:3BSP1Tbs2djlpprl7wCLuiqMaUh5SJkkzI2gDs+FgLs= 13 | github.com/gogs/chardet v0.0.0-20211120154057-b7413eaefb8f/go.mod h1:Pcatq5tYkCW2Q6yrR2VRHlbHpZ/R4/7qyL1TCF7vl14= 14 | github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= 15 | github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 16 | github.com/jaytaylor/html2text v0.0.0-20230321000545-74c2419ad056 h1:iCHtR9CQyktQ5+f3dMVZfwD2KWJUgm7M0gdL9NGr8KA= 17 | github.com/jaytaylor/html2text v0.0.0-20230321000545-74c2419ad056/go.mod h1:CVKlgaMiht+LXvHG173ujK6JUhZXKb2u/BQtjPDIvyk= 18 | github.com/jhillyerd/enmime v1.3.0 h1:LV5kzfLidiOr8qRGIpYYmUZCnhrPbcFAnAFUnWn99rw= 19 | github.com/jhillyerd/enmime v1.3.0/go.mod h1:6c6jg5HdRRV2FtvVL69LjiX1M8oE0xDX9VEhV3oy4gs= 20 | github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= 21 | github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U= 22 | github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= 23 | github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec= 24 | github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY= 25 | github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= 26 | github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 27 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 28 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 29 | github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= 30 | github.com/rivo/uniseg v0.4.4 h1:8TfxU8dW6PdqD27gjM8MVNuicgxIjxpm4K7x4jp8sis= 31 | github.com/rivo/uniseg v0.4.4/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= 32 | github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= 33 | github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= 34 | github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf h1:pvbZ0lM0XWPBqUKqFU8cmavspvIl9nulOYwdy6IFRRo= 35 | github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf/go.mod h1:RJID2RhlZKId02nZ62WenDCkgHFerpIOmW0iT7GKmXM= 36 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 37 | github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 38 | github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= 39 | github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= 40 | github.com/toorop/go-dkim v0.0.0-20250226130143-9025cce95817 h1:q0hKh5a5FRkhuTb5JNfgjzpzvYLHjH0QOgPZPYnRWGA= 41 | github.com/toorop/go-dkim v0.0.0-20250226130143-9025cce95817/go.mod h1:BzWtXXrXzZUvMacR0oF/fbDDgUPO8L36tDMmRAf14ns= 42 | github.com/yumusb/go-smtp v0.0.0-20241013125232-63ad8b4f888a h1:8TJ1dzFyGtcbPrCf10Vj2vHU3MFQLc50l2sGgl8BEMQ= 43 | github.com/yumusb/go-smtp v0.0.0-20241013125232-63ad8b4f888a/go.mod h1:6H9N8q5t/2FIrI4cLbenXY+ecPmEn2BOVK5JTOhHWZ8= 44 | golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8= 45 | golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8= 46 | golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 47 | golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= 48 | golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= 49 | golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= 50 | golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= 51 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 52 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 53 | gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= 54 | gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= 55 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 56 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 57 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 58 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "crypto/tls" 6 | "fmt" 7 | "io" 8 | "mime" 9 | "net" 10 | "os" 11 | "regexp" 12 | "strings" 13 | "time" 14 | 15 | "github.com/jhillyerd/enmime" 16 | //"github.com/mileusna/spf" 17 | "blitiri.com.ar/go/spf" 18 | "github.com/sirupsen/logrus" // 引入logrus包 19 | "github.com/yumusb/go-smtp" 20 | ) 21 | 22 | func main() { 23 | // 设置logrus为JSON格式 24 | logrus.SetFormatter(&logrus.JSONFormatter{}) 25 | logrus.SetOutput(os.Stdout) 26 | logrus.SetLevel(logrus.InfoLevel) 27 | 28 | // 加载配置 29 | err := LoadConfig("config.yml") 30 | if err != nil { 31 | logrus.Fatalf("Error loading config: %v", err) 32 | } 33 | // 输出DMARC配置信息 34 | if CONFIG.SMTP.EnableDMARC { 35 | // 检查私钥有效性 36 | if _, pkErr := extractPublicKeyInfo(CONFIG.SMTP.DKIMPrivateKey); pkErr != nil { 37 | logrus.Errorf("DKIM私钥无效: %v", pkErr) 38 | logrus.Info("请使用以下命令生成新的DKIM私钥:") 39 | logrus.Info("openssl genrsa -out dkim_private.pem 2048") 40 | //logrus.Info("openssl rsa -in dkim_private.pem -pubout -out dkim_public.pem") 41 | logrus.Info("然后将生成的私钥内容配置到config.yml的DKIMPrivateKey字段中") 42 | return 43 | } 44 | logrus.Infof("DMARC 已启用,使用选择器: %s", CONFIG.SMTP.DKIMSelector) 45 | } else { 46 | logrus.Infof("DMARC 未启用") 47 | } 48 | // 推荐的DNS记录 49 | for _, domain := range CONFIG.SMTP.AllowedDomains { 50 | logrus.Infof("\n域名: %s", domain) 51 | logrus.Infof(";; A Records") 52 | logrus.Infof("mx.%s.\t1\tIN\tA\t%s", domain, "ip地址") 53 | logrus.Infof("\n;; MX Records") 54 | logrus.Infof("%s.\t1\tIN\tMX\t5 mx.%s.", domain, domain) 55 | logrus.Infof("\n;; TXT Records") 56 | logrus.Infof("%s.\t1\tIN\tTXT\t\"v=spf1 mx:%s -all\"", domain, domain) 57 | if CONFIG.SMTP.EnableDMARC { 58 | logrus.Infof("_dmarc.%s.\t1\tIN\tTXT\t\"v=DMARC1; p=reject; ruf=mailto:dmarc@%s; fo=1;\"", 59 | domain, domain) 60 | logrus.Infof("%s._domainkey.%s.\t1\tIN\tTXT\t\"v=DKIM1; k=rsa; p=%s\"", 61 | CONFIG.SMTP.DKIMSelector, domain, func() string { 62 | pubKey, pkErr := extractPublicKeyInfo(CONFIG.SMTP.DKIMPrivateKey) 63 | if err != nil { 64 | logrus.Errorf("获取公钥信息失败: %v", pkErr) 65 | return "" 66 | } 67 | return pubKey 68 | }()) 69 | } 70 | } 71 | 72 | logrus.Infof("SMTP 监听地址: %s", CONFIG.SMTP.ListenAddress) 73 | logrus.Infof("SMTP TLS 监听地址: %s", CONFIG.SMTP.ListenAddressTls) 74 | logrus.Infof("SMTP 允许的域名: %v", CONFIG.SMTP.AllowedDomains) 75 | 76 | logrus.Infof("Telegram Chat ID: %s", CONFIG.Telegram.ChatID) 77 | //spf.DNSServer = "1.1.1.1:53" 78 | 79 | be := &Backend{} 80 | 81 | // Plain SMTP server with STARTTLS support 82 | plainServer := smtp.NewServer(be) 83 | plainServer.Addr = CONFIG.SMTP.ListenAddress 84 | plainServer.Domain = GetEnv("MXDOMAIN", "localhost") 85 | plainServer.WriteTimeout = 10 * time.Second 86 | plainServer.ReadTimeout = 10 * time.Second 87 | plainServer.MaxMessageBytes = 1024 * 1024 88 | plainServer.MaxRecipients = 50 89 | plainServer.AllowInsecureAuth = false // Change to true if you want to allow plain auth before STARTTLS (not recommended) 90 | 91 | // Attempt to load TLS configuration for STARTTLS and SMTPS 92 | cer, err := tls.LoadX509KeyPair(CONFIG.SMTP.CertFile, CONFIG.SMTP.KeyFile) 93 | if err != nil { 94 | logrus.Warnf("Loading TLS certificate failed: %v", err) 95 | logrus.Infof("Starting plainServer only at %s", CONFIG.SMTP.ListenAddress) 96 | 97 | // Start only the plain SMTP server with STARTTLS in a new goroutine 98 | if err := plainServer.ListenAndServe(); err != nil { 99 | logrus.Fatal(err) 100 | } 101 | } else { 102 | // Certificate loaded successfully, configure STARTTLS 103 | plainServer.TLSConfig = &tls.Config{Certificates: []tls.Certificate{cer}} 104 | 105 | // SMTPS server (TLS only) 106 | tlsServer := smtp.NewServer(be) 107 | tlsServer.Addr = CONFIG.SMTP.ListenAddressTls 108 | tlsServer.Domain = GetEnv("MXDOMAIN", "localhost") 109 | tlsServer.WriteTimeout = 10 * time.Second 110 | tlsServer.ReadTimeout = 10 * time.Second 111 | tlsServer.MaxMessageBytes = 1024 * 1024 112 | tlsServer.MaxRecipients = 50 113 | tlsServer.AllowInsecureAuth = false 114 | tlsServer.TLSConfig = &tls.Config{Certificates: []tls.Certificate{cer}} 115 | 116 | // Start the plain SMTP server with STARTTLS in a new goroutine 117 | go func() { 118 | logrus.Infof("Starting plainServer at %s", CONFIG.SMTP.ListenAddress) 119 | if err := plainServer.ListenAndServe(); err != nil { 120 | logrus.Fatal(err) 121 | } 122 | }() 123 | 124 | // Start the SMTPS server (TLS only) 125 | logrus.Infof("Starting tlsServer at %s", CONFIG.SMTP.ListenAddressTls) 126 | if err := tlsServer.ListenAndServeTLS(); err != nil { 127 | logrus.Fatal(err) 128 | } 129 | } 130 | } 131 | func SPFCheck(s *Session) *smtp.SMTPError { 132 | remoteHost, _, err := net.SplitHostPort(s.remoteIP) 133 | if err != nil { 134 | logrus.Warn("parse remote addr failed") 135 | return &smtp.SMTPError{Code: 550, EnhancedCode: smtp.EnhancedCode{5, 1, 0}, Message: "Invalid remote address"} 136 | } 137 | remoteIP := net.ParseIP(remoteHost) 138 | s.spfResult, err = spf.CheckHostWithSender(remoteIP, s.remoteclientHostname, s.from) 139 | if err != nil { 140 | logrus.Warnf("SPF check Result: %v - UUID: %s", err, s.UUID) 141 | //return &smtp.SMTPError{Code: 550, EnhancedCode: smtp.EnhancedCode{5, 7, 0}, Message: "SPF check failed"} 142 | } 143 | logrus.Infof("SPF Result: %v - Domain: %s, Remote IP: %s, Sender: %s - UUID: %s", s.spfResult, getDomainFromEmail(s.from), remoteHost, s.from, s.UUID) 144 | switch s.spfResult { 145 | case spf.None: 146 | logrus.Warnf("SPF Result: NONE - No SPF record found for domain %s. Rejecting email.", getDomainFromEmail(s.from)) 147 | return &smtp.SMTPError{Code: 450, EnhancedCode: smtp.EnhancedCode{5, 0, 0}, Message: "SPF check softfail (no SPF record)"} 148 | case spf.Neutral: 149 | logrus.Infof("SPF Result: NEUTRAL - Domain %s neither permits nor denies sending mail from IP %s", getDomainFromEmail(s.from), s.remoteIP) 150 | case spf.Pass: 151 | logrus.Infof("SPF Result: PASS - SPF check passed for domain %s, email is legitimate", getDomainFromEmail(s.from)) 152 | case spf.Fail: 153 | logrus.Warnf("SPF Result: FAIL - SPF check failed for domain %s, mail from IP %s is unauthorized", getDomainFromEmail(s.from), s.remoteIP) 154 | return &smtp.SMTPError{Code: 550, EnhancedCode: smtp.EnhancedCode{5, 7, 0}, Message: "SPF check failed"} 155 | case spf.SoftFail: 156 | logrus.Warnf("SPF Result: SOFTFAIL - SPF check soft failed for domain %s, email is suspicious", getDomainFromEmail(s.from)) 157 | return &smtp.SMTPError{Code: 450, EnhancedCode: smtp.EnhancedCode{5, 0, 1}, Message: "SPF check softfail"} 158 | case spf.TempError: 159 | logrus.Warnf("SPF Result: TEMPERROR - Temporary SPF error occurred for domain %s, retry might succeed", getDomainFromEmail(s.from)) 160 | return &smtp.SMTPError{Code: 451, EnhancedCode: smtp.EnhancedCode{4, 0, 0}, Message: "Temporary SPF check error"} 161 | case spf.PermError: 162 | logrus.Warnf("SPF Result: PERMERROR - Permanent SPF error for domain %s, SPF record is invalid", getDomainFromEmail(s.from)) 163 | return &smtp.SMTPError{Code: 550, EnhancedCode: smtp.EnhancedCode{5, 1, 2}, Message: "SPF check permanent error"} 164 | } 165 | return nil // SPF 检查通过,返回 nil 166 | } 167 | 168 | func (s *Session) Data(r io.Reader) error { 169 | buf := new(bytes.Buffer) 170 | _, err := buf.ReadFrom(r) 171 | if err != nil { 172 | return fmt.Errorf("error reading data: %v", err) 173 | } 174 | data := buf.Bytes() 175 | env, err := enmime.ReadEnvelope(bytes.NewReader(data)) 176 | if err != nil { 177 | logrus.Errorf("Failed to parse email: %v - UUID: %s", err, s.UUID) 178 | return err 179 | } 180 | logrus.Infof("Received email: From=%s HeaderTo=%s ParsedTo=%v Subject=%s - UUID: %s", 181 | env.GetHeader("From"), 182 | env.GetHeader("To"), 183 | s.to, 184 | env.GetHeader("Subject"), 185 | s.UUID) 186 | 187 | var attachments []string 188 | for _, attachment := range env.Attachments { 189 | disposition := attachment.Header.Get("Content-Disposition") 190 | if disposition != "" { 191 | _, params, _ := mime.ParseMediaType(disposition) 192 | if filename, ok := params["filename"]; ok { 193 | attachments = append(attachments, filename) 194 | } 195 | } 196 | } 197 | parsedContent := fmt.Sprintf( 198 | "📧 New Email Notification\n"+ 199 | "=================================\n"+ 200 | "📤 From: %s\n"+ 201 | "📬 To: %s\n"+ 202 | "---------------------------------\n"+ 203 | "🔍 SPF Status: %s\n"+ 204 | "📝 Subject: %s\n"+ 205 | "📅 Date: %s\n"+ 206 | "📄 Content-Type: %s\n"+ 207 | "=================================\n\n"+ 208 | "✉️ Email Body:\n\n%s\n\n"+ 209 | "=================================\n"+ 210 | "📎 Attachments:\n%s\n"+ 211 | "=================================\n"+ 212 | "🔑 UUID: %s", 213 | s.from, 214 | strings.Join(s.to, ", "), 215 | s.spfResult, 216 | env.GetHeader("Subject"), 217 | env.GetHeader("Date"), 218 | getPrimaryContentType(env.GetHeader("Content-Type")), 219 | env.Text, 220 | strings.Join(attachments, "\n"), 221 | s.UUID, 222 | ) 223 | parsedTitle := fmt.Sprintf("📬 New Email: %s", env.GetHeader("Subject")) 224 | s.msgId = env.GetHeader("Message-ID") 225 | if s.msgId == "" { 226 | s.msgId = env.GetHeader("Message-Id") 227 | } 228 | sender := extractEmails(env.GetHeader("From")) 229 | recipient := getFirstMatchingEmail(s.to) 230 | if !strings.EqualFold(sender, CONFIG.SMTP.PrivateEmail) && !strings.Contains(recipient, "_at_") && !regexp.MustCompile(`^(\w|-)+@.+$`).MatchString(recipient) { 231 | // 验证收件人的规则 232 | logrus.Warnf("不符合规则的收件人,需要是 random@qq.com、ran-dom@qq.com,当前为 %s - UUID: %s", recipient, s.UUID) 233 | return &smtp.SMTPError{ 234 | Code: 550, 235 | EnhancedCode: smtp.EnhancedCode{5, 1, 0}, 236 | Message: "Invalid recipient", 237 | } 238 | } 239 | var outsite2private bool 240 | outsite2private = false 241 | if CONFIG.SMTP.PrivateEmail != "" { 242 | formattedSender := "" 243 | targetAddress := "" 244 | if strings.EqualFold(sender, CONFIG.SMTP.PrivateEmail) && strings.Contains(recipient, "_at_") { 245 | // 来自私密邮箱,需要将邮件转发到目标邮箱 246 | originsenderEmail, selfsenderEmail := parseEmails(recipient) 247 | targetAddress = originsenderEmail 248 | formattedSender = selfsenderEmail 249 | outsite2private = false 250 | logrus.Infof("Private 2 outside, ([%s] → [%s]) changed to ([%s] → [%s]) - UUID: %s", sender, recipient, formattedSender, targetAddress, s.UUID) 251 | } else if strings.EqualFold(sender, CONFIG.SMTP.PrivateEmail) && !strings.Contains(recipient, "_at_") { 252 | // 来自私密邮箱,但目标邮箱写的有问题 253 | logrus.Infof("not need forward, from %s to %s - UUID: %s", sender, recipient, s.UUID) 254 | // 不需要转发,但是可能需要通知给用户。 255 | return nil 256 | } else { 257 | // 来自非私密邮箱,需要将邮件转发到私密邮箱 258 | domain := getDomainFromEmail(recipient) 259 | formattedSender = fmt.Sprintf("%s_%s@%s", 260 | strings.ReplaceAll(strings.ReplaceAll(sender, "@", "_at_"), ".", "_"), 261 | strings.Split(recipient, "@")[0], 262 | domain) 263 | targetAddress = CONFIG.SMTP.PrivateEmail 264 | logrus.Infof("Outside 2 private, ([%s] → [%s]) changed to ([%s] → [%s]) - UUID: %s", sender, recipient, formattedSender, targetAddress, s.UUID) 265 | outsite2private = true 266 | } 267 | go forwardEmailToTargetAddress(data, formattedSender, targetAddress, s) 268 | if outsite2private { 269 | if CONFIG.Telegram.ChatID != "" { 270 | go sendToTelegramBot(parsedContent, s.UUID) 271 | if CONFIG.Telegram.SendEML { 272 | go sendRawEMLToTelegram(data, env.GetHeader("Subject"), s.UUID) 273 | } else { 274 | logrus.Info("Telegram EML is disabled.") 275 | } 276 | } else { 277 | logrus.Info("Telegram is disabled.") 278 | } 279 | if CONFIG.Webhook.Enabled { 280 | go sendWebhook(CONFIG.Webhook, parsedTitle, parsedContent, s.UUID) 281 | } else { 282 | logrus.Info("Webhook is disabled.") 283 | } 284 | } 285 | } else { 286 | logrus.Info("Email forwarder is disabled.") 287 | } 288 | return nil 289 | } 290 | -------------------------------------------------------------------------------- /func.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "crypto/rsa" 6 | "crypto/tls" 7 | "crypto/x509" 8 | "encoding/base64" 9 | "encoding/json" 10 | "encoding/pem" 11 | "errors" 12 | "fmt" 13 | "io" 14 | "mime/multipart" 15 | "net" 16 | "net/http" 17 | "net/mail" 18 | "net/url" 19 | "os" 20 | "regexp" 21 | "strings" 22 | "time" 23 | 24 | "github.com/google/uuid" 25 | "github.com/sirupsen/logrus" 26 | "github.com/toorop/go-dkim" // 添加 DKIM 库 27 | "github.com/yumusb/go-smtp" 28 | "gopkg.in/yaml.v2" 29 | ) 30 | 31 | func NewUUID() string { 32 | uuidV4 := uuid.New() 33 | return uuidV4.String() 34 | } 35 | func GetEnv(key, defaultValue string) string { 36 | if value, exists := os.LookupEnv(key); exists { 37 | return value 38 | } 39 | return defaultValue 40 | } 41 | func LoadConfig(filePath string) error { 42 | data, err := os.ReadFile(filePath) 43 | if err != nil { 44 | return err 45 | } 46 | err = yaml.Unmarshal(data, &CONFIG) 47 | if err != nil { 48 | return err 49 | } 50 | return nil 51 | } 52 | func isValidEmail(email string) bool { 53 | _, err := mail.ParseAddress(email) 54 | return err == nil 55 | } 56 | func extractEmails(str string) string { 57 | str = strings.TrimSpace(str) 58 | address, err := mail.ParseAddress(str) 59 | if err != nil { 60 | return str 61 | } 62 | return address.Address 63 | } 64 | func removeEmailHeaders(emailData []byte, headersToRemove []string) ([]byte, error) { 65 | msg, err := mail.ReadMessage(bytes.NewReader(emailData)) 66 | if err != nil { 67 | return nil, err 68 | } 69 | 70 | // 读取原始邮件头 71 | headers := make(map[string]string) 72 | for k, v := range msg.Header { 73 | headers[strings.ToLower(k)] = strings.Join(v, ", ") // 统一存储为小写 74 | } 75 | 76 | // 创建正则表达式模式 77 | patterns := make([]*regexp.Regexp, len(headersToRemove)) 78 | for i, header := range headersToRemove { 79 | regexPattern := "^" + regexp.QuoteMeta(strings.ToLower(header)) + "$" 80 | regexPattern = strings.ReplaceAll(regexPattern, "\\*.", ".*") // 处理 *. 形式 81 | regexPattern = strings.ReplaceAll(regexPattern, "\\*", ".*") // 处理 * 形式 82 | patterns[i] = regexp.MustCompile(regexPattern) 83 | } 84 | 85 | // 移除匹配的 headers 86 | for k := range headers { 87 | for _, pattern := range patterns { 88 | if pattern.MatchString(k) { 89 | delete(headers, k) 90 | break 91 | } 92 | } 93 | } 94 | 95 | // 读取邮件正文 96 | body, err := io.ReadAll(msg.Body) 97 | if err != nil { 98 | return nil, err 99 | } 100 | 101 | // 重新构造邮件内容 102 | var buf bytes.Buffer 103 | for k, v := range headers { 104 | fmt.Fprintf(&buf, "%s: %s\r\n", k, v) 105 | } 106 | buf.WriteString("\r\n") // 头部结束 107 | 108 | buf.Write(body) // 追加原始正文 109 | 110 | return buf.Bytes(), nil 111 | } 112 | func addEmailHeaders(emailData []byte, headersToAdd map[string]string) ([]byte, error) { 113 | msg, err := mail.ReadMessage(bytes.NewReader(emailData)) 114 | if err != nil { 115 | return nil, err 116 | } 117 | 118 | // Read the original email headers 119 | headers := make(map[string]string) 120 | for k, v := range msg.Header { 121 | headers[k] = strings.Join(v, ", ") // Store headers with original casing 122 | } 123 | 124 | // Add the specified headers with the prefix and uppercase keys 125 | for header, value := range headersToAdd { 126 | newheader := "" 127 | if strings.Contains(header, "Original") { 128 | newheader = strings.ToUpper(headerPrefix + header) 129 | } else { 130 | newheader = header 131 | } 132 | if existingValue, exists := headers[newheader]; exists { 133 | // If the header already exists, append the new value 134 | headers[newheader] = existingValue + ", " + value 135 | } else { 136 | headers[newheader] = value 137 | } 138 | } 139 | 140 | // Build the new email content with added headers 141 | var buf bytes.Buffer 142 | for k, v := range headers { 143 | fmt.Fprintf(&buf, "%s: %s\r\n", k, v) 144 | } 145 | buf.WriteString("\r\n") 146 | 147 | // Append the original email body 148 | body, err := io.ReadAll(msg.Body) 149 | if err != nil { 150 | return nil, err 151 | } 152 | buf.Write(body) 153 | 154 | return buf.Bytes(), nil 155 | } 156 | 157 | func modifyEmailHeaders(emailData []byte, newSender, newRecipient string) ([]byte, error) { 158 | msg, err := mail.ReadMessage(bytes.NewReader(emailData)) 159 | if err != nil { 160 | return nil, err 161 | } 162 | // Read the original email headers 163 | headers := make(map[string]string) 164 | for k, v := range msg.Header { 165 | headers[k] = strings.Join(v, ", ") 166 | } 167 | // Modify the 'From' header 168 | if newSender != "" { 169 | headers["From"] = newSender 170 | } 171 | // Modify the 'To' header 172 | if newRecipient != "" { 173 | headers["To"] = newRecipient 174 | } 175 | // Build the new email content 176 | var buf bytes.Buffer 177 | for k, v := range headers { 178 | fmt.Fprintf(&buf, "%s: %s\r\n", k, v) 179 | } 180 | buf.WriteString("\r\n") 181 | // Append the original email body 182 | body, err := io.ReadAll(msg.Body) 183 | if err != nil { 184 | return nil, err 185 | } 186 | buf.Write(body) 187 | return buf.Bytes(), nil 188 | } 189 | func checkDomain(email, domain string) bool { 190 | return strings.HasSuffix(strings.ToLower(email), "@"+strings.ToLower(domain)) 191 | } 192 | func getDomainFromEmail(email string) string { 193 | address, err := mail.ParseAddress(email) 194 | if err != nil { 195 | return "" 196 | } 197 | at := strings.LastIndex(address.Address, "@") 198 | if at == -1 { 199 | return "" 200 | } 201 | return address.Address[at+1:] 202 | } 203 | func parseEmails(input string) (string, string) { 204 | lastUnderscoreIndex := strings.LastIndex(input, "_") 205 | if lastUnderscoreIndex == -1 { 206 | return "", "" 207 | } 208 | secondEmail := input[lastUnderscoreIndex+1:] 209 | firstPart := input[:lastUnderscoreIndex] 210 | firstEmail := strings.ReplaceAll(firstPart, "_at_", "@") 211 | firstEmail = strings.ReplaceAll(firstEmail, "_", ".") 212 | return firstEmail, secondEmail 213 | } 214 | 215 | func getSMTPServer(domain string) (string, error) { 216 | mxRecords, err := net.LookupMX(domain) 217 | if err != nil { 218 | return "", fmt.Errorf("failed to lookup MX records: %v", err) 219 | } 220 | if len(mxRecords) == 0 { 221 | return "", fmt.Errorf("no MX records found for domain: %s", domain) 222 | } 223 | return mxRecords[0].Host, nil 224 | } 225 | func isCertInvalidError(err error) bool { 226 | if err == nil { 227 | return false 228 | } 229 | // Check if the error contains information about an invalid certificate 230 | if strings.Contains(err.Error(), "x509: certificate signed by unknown authority") || 231 | strings.Contains(err.Error(), "certificate is not trusted") || 232 | strings.Contains(err.Error(), "tls: failed to verify certificate") { 233 | return true 234 | } 235 | return false 236 | } 237 | func (s *Session) Reset() {} 238 | 239 | func (s *Session) Logout() error { 240 | return nil 241 | } 242 | func (bkd *Backend) NewSession(c *smtp.Conn) (smtp.Session, error) { 243 | remoteIP := c.Conn().RemoteAddr().String() 244 | localIP := c.Conn().LocalAddr().String() 245 | remoteclientHostname := c.Hostname() 246 | id := NewUUID() 247 | logrus.Infof("New connection from %s (%s) to %s - UUID: %s", remoteIP, remoteclientHostname, localIP, id) 248 | session := &Session{ 249 | remoteIP: remoteIP, 250 | localIP: localIP, 251 | remoteclientHostname: remoteclientHostname, 252 | UUID: id, 253 | } 254 | return session, nil 255 | } 256 | 257 | func (s *Session) Mail(from string, opts *smtp.MailOptions) error { 258 | if !isValidEmail(from) { 259 | return errors.New("invalid email address format") 260 | } 261 | s.from = from 262 | spfCheckErr := SPFCheck(s) 263 | if spfCheckErr != nil { 264 | logrus.Errorf("SPF check failed: %v - UUID: %s", spfCheckErr, s.UUID) 265 | return spfCheckErr 266 | } 267 | return nil 268 | } 269 | func (s *Session) Rcpt(to string, opts *smtp.RcptOptions) error { 270 | if !isValidEmail(to) { 271 | return errors.New("invalid email address format") 272 | } 273 | s.to = append(s.to, to) 274 | if !shouldForwardEmail(s.to) { 275 | logrus.Warnf("Not handled by this mail server, %s - UUID: %s", s.to, s.UUID) 276 | return &smtp.SMTPError{ 277 | Code: 554, 278 | EnhancedCode: smtp.EnhancedCode{5, 7, 1}, 279 | Message: "Domain not handled by this mail server", 280 | } 281 | } 282 | return nil 283 | } 284 | func splitMessage(message string, maxLength int) []string { 285 | var messages []string 286 | runes := []rune(message) // 支持多字节字符 287 | for len(runes) > maxLength { 288 | // 尝试在最后一个空格处分割,避免将单词或句子截断 289 | splitIndex := maxLength 290 | for splitIndex > 0 && runes[splitIndex] != ' ' { 291 | splitIndex-- 292 | } 293 | if splitIndex == 0 { 294 | splitIndex = maxLength // 如果找不到空格,就强制在 maxLength 处截断 295 | } 296 | messages = append(messages, string(runes[:splitIndex])) 297 | runes = runes[splitIndex:] 298 | } 299 | messages = append(messages, string(runes)) // 追加最后的剩余部分 300 | return messages 301 | } 302 | 303 | func sendToTelegramBot(message string, traceid string) { 304 | botToken := CONFIG.Telegram.BotToken 305 | chatID := CONFIG.Telegram.ChatID 306 | apiURL := fmt.Sprintf("https://api.telegram.org/bot%s/sendMessage", botToken) 307 | 308 | // 分割消息 309 | messages := splitMessage(message, telegramMaxLength) 310 | 311 | // 依次发送每个分割后的消息 312 | for _, msgPart := range messages { 313 | payload := map[string]interface{}{ 314 | "chat_id": chatID, 315 | "text": msgPart, 316 | } 317 | jsonPayload, err := json.Marshal(payload) 318 | if err != nil { 319 | logrus.Errorf("Failed to marshal JSON payload - TraceID: %s, Error: %v", traceid, err) 320 | return 321 | } 322 | 323 | resp, err := http.Post(apiURL, "application/json", bytes.NewBuffer(jsonPayload)) 324 | if err != nil { 325 | logrus.Errorf("Failed to send message to Telegram bot - TraceID: %s, Error: %v", traceid, err) 326 | return 327 | } 328 | defer resp.Body.Close() 329 | 330 | logrus.Infof("Message sent to Telegram bot - TraceID: %s, Response: %s", traceid, resp.Status) 331 | if resp.StatusCode != 200 { 332 | logrus.Warnf("Non-200 response from Telegram bot - TraceID: %s", traceid) 333 | } 334 | } 335 | } 336 | 337 | func sendRawEMLToTelegram(emailData []byte, subject string, traceid string) { 338 | botToken := CONFIG.Telegram.BotToken 339 | chatID := CONFIG.Telegram.ChatID 340 | apiURL := fmt.Sprintf("https://api.telegram.org/bot%s/sendDocument", botToken) 341 | tmpFile, err := os.CreateTemp("", "email-*.eml") 342 | if err != nil { 343 | logrus.Errorf("Failed to create temporary file - TraceID: %s, Error: %v", traceid, err) 344 | return 345 | } 346 | defer func() { 347 | tmpFile.Close() 348 | os.Remove(tmpFile.Name()) 349 | }() 350 | 351 | _, err = tmpFile.Write(emailData) 352 | if err != nil { 353 | logrus.Errorf("Failed to write email data to file - TraceID: %s, Error: %v", traceid, err) 354 | return 355 | } 356 | 357 | // 使用安全的文件权限 358 | err = os.Chmod(tmpFile.Name(), 0600) 359 | if err != nil { 360 | logrus.Errorf("Failed to set file permissions - TraceID: %s, Error: %v", traceid, err) 361 | return 362 | } 363 | 364 | tmpFile.Seek(0, 0) 365 | file, err := os.Open(tmpFile.Name()) 366 | if err != nil { 367 | logrus.Errorf("Failed to open temporary file - TraceID: %s, Error: %v", traceid, err) 368 | return 369 | } 370 | defer file.Close() 371 | 372 | body := &bytes.Buffer{} 373 | writer := multipart.NewWriter(body) 374 | part, err := writer.CreateFormFile("document", tmpFile.Name()) 375 | if err != nil { 376 | logrus.Errorf("Failed to create form file - TraceID: %s, Error: %v", traceid, err) 377 | return 378 | } 379 | _, err = io.Copy(part, file) 380 | if err != nil { 381 | logrus.Errorf("Failed to copy file data - TraceID: %s, Error: %v", traceid, err) 382 | return 383 | } 384 | 385 | _ = writer.WriteField("chat_id", chatID) 386 | _ = writer.WriteField("caption", subject) 387 | err = writer.Close() 388 | if err != nil { 389 | logrus.Errorf("Failed to close writer - TraceID: %s, Error: %v", traceid, err) 390 | return 391 | } 392 | 393 | req, err := http.NewRequest("POST", apiURL, body) 394 | if err != nil { 395 | logrus.Errorf("Failed to create HTTP request - TraceID: %s, Error: %v", traceid, err) 396 | return 397 | } 398 | req.Header.Add("Content-Type", writer.FormDataContentType()) 399 | client := &http.Client{} 400 | resp, err := client.Do(req) 401 | if err != nil { 402 | logrus.Errorf("Failed to send email as EML to Telegram - TraceID: %s, Error: %v", traceid, err) 403 | return 404 | } 405 | defer resp.Body.Close() 406 | logrus.Infof("Raw EML sent to Telegram bot - TraceID: %s, Response: %s", traceid, resp.Status) 407 | } 408 | func checkDMARCRecord(domain string) (bool, error) { 409 | dmarcDomain := "_dmarc." + domain 410 | txtRecords, err := net.LookupTXT(dmarcDomain) 411 | if err != nil { 412 | // 如果查询出错,可能是没有DMARC记录或DNS查询失败 413 | if dnsErr, ok := err.(*net.DNSError); ok && dnsErr.IsNotFound { 414 | return false, nil // 域名存在但没有DMARC记录 415 | } 416 | return false, err // 其他DNS错误 417 | } 418 | // 检查是否有DMARC记录 419 | for _, record := range txtRecords { 420 | if strings.HasPrefix(strings.ToLower(record), "v=dmarc1") { 421 | return true, nil // 找到DMARC记录 422 | } 423 | } 424 | return false, nil // 没有找到DMARC记录 425 | } 426 | func forwardEmailToTargetAddress(emailData []byte, formattedSender string, targetAddress string, s *Session) { 427 | logrus.Infof("Preparing to forward email from [%s] to [%s] - UUID: %s", formattedSender, targetAddress, s.UUID) 428 | if formattedSender == "" || targetAddress == "" { 429 | logrus.Warnf("Address error: either sender or recipient address is empty - UUID: %s", s.UUID) 430 | return 431 | } 432 | targetDomain := strings.SplitN(targetAddress, "@", 2)[1] 433 | senderDomain := strings.SplitN(formattedSender, "@", 2)[1] 434 | 435 | // 检查是否需要应用DMARC签名 436 | useDMARC := false 437 | if CONFIG.SMTP.EnableDMARC { 438 | // 应该检查发件人域名的DMARC记录,而不是接收方域名 439 | hasDMARC, err := checkDMARCRecord(senderDomain) 440 | if err != nil { 441 | logrus.Warnf("无法检查发件人域名 [%s] 的DMARC记录: %v - UUID: %s", senderDomain, err, s.UUID) 442 | } else if hasDMARC { 443 | logrus.Infof("发件人域名 [%s] 存在DMARC记录,将应用DMARC签名 - UUID: %s", senderDomain, s.UUID) 444 | useDMARC = true 445 | } else { 446 | logrus.Infof("发件人域名 [%s] 没有DMARC记录 - UUID: %s", senderDomain, s.UUID) 447 | } 448 | } else { 449 | logrus.Debugf("DMARC签名在配置中已禁用 - UUID: %s", s.UUID) 450 | } 451 | 452 | smtpServer, err := getSMTPServer(targetDomain) 453 | if err != nil { 454 | logrus.Errorf("Error retrieving SMTP server for domain [%s]: %v - UUID: %s", targetDomain, err, s.UUID) 455 | return 456 | } 457 | 458 | // Attempt to connect to SMTP server using plain connection on port 25 459 | conn, err := tryDialSMTPPlain(smtpServer, 25) 460 | if err != nil { 461 | logrus.Errorf("Failed to establish connection on port 25: %v - UUID: %s", err, s.UUID) 462 | return 463 | } 464 | defer conn.Close() 465 | 466 | // Attempt to initiate STARTTLS for secure email transmission 467 | tlsConfig := &tls.Config{ 468 | ServerName: smtpServer, 469 | } 470 | client, err := smtp.NewClientStartTLSWithLocalName(conn, tlsConfig, getDomainFromEmail(formattedSender)) 471 | if err != nil { 472 | logrus.Errorf("Failed to establish STARTTLS: %v - UUID: %s", err, s.UUID) 473 | logrus.Warnf("Downgrading to plain SMTP due to failed STARTTLS handshake - UUID: %s", s.UUID) 474 | conn.Close() 475 | conn, err = tryDialSMTPPlain(smtpServer, 25) 476 | if err != nil { 477 | logrus.Errorf("Failed to reconnect on port 25 for plain SMTP: %v - UUID: %s", err, s.UUID) 478 | return 479 | } 480 | defer conn.Close() 481 | client = smtp.NewClientWithLocalName(conn, getDomainFromEmail(formattedSender)) // Re-create the SMTP client without encryption 482 | } else { 483 | logrus.Infof("STARTTLS connection established successfully with [%s] - UUID: %s", smtpServer, s.UUID) 484 | } 485 | 486 | // Ensure the client connection is properly closed 487 | defer func() { 488 | if client != nil { 489 | client.Quit() // Attempt to gracefully close the connection with QUIT 490 | client.Close() 491 | } 492 | }() 493 | 494 | // Set the MAIL FROM command with the sender address 495 | err = client.Mail(formattedSender, &smtp.MailOptions{}) 496 | if err != nil { 497 | if isCertInvalidError(err) { 498 | logrus.Errorf("TLS certificate validation failed: %v - UUID: %s", err, s.UUID) 499 | logrus.Warnf("Falling back to plain SMTP as certificate verification failed - UUID: %s", s.UUID) 500 | conn.Close() 501 | conn, err = tryDialSMTPPlain(smtpServer, 25) 502 | if err != nil { 503 | logrus.Errorf("Failed to reconnect on port 25 for plain SMTP after TLS failure: %v - UUID: %s", err, s.UUID) 504 | return 505 | } 506 | defer conn.Close() 507 | client = smtp.NewClientWithLocalName(conn, getDomainFromEmail(formattedSender)) 508 | if mailErr := client.Mail(formattedSender, &smtp.MailOptions{}); mailErr != nil { 509 | logrus.Errorf("Error setting MAIL FROM on plain SMTP: %v - UUID: %s", mailErr, s.UUID) 510 | return 511 | } 512 | } else { 513 | logrus.Errorf("Error setting MAIL FROM: %v - UUID: %s", err, s.UUID) 514 | if smtpErr, ok := err.(*smtp.SMTPError); ok && smtpErr.Code >= 500 { 515 | logrus.Errorf("MAIL FROM rejected by server with code %d: %v - UUID: %s", smtpErr.Code, smtpErr, s.UUID) 516 | return 517 | } 518 | logrus.Errorf("Error setting MAIL FROM: %v - UUID: %s", err, s.UUID) 519 | return 520 | } 521 | } 522 | 523 | // Set the RCPT TO command with the recipient address 524 | err = client.Rcpt(targetAddress, &smtp.RcptOptions{}) 525 | if err != nil { 526 | if smtpErr, ok := err.(*smtp.SMTPError); ok && smtpErr.Code >= 500 { 527 | logrus.Errorf("RCPT TO rejected by server with code %d: %v - UUID: %s", smtpErr.Code, smtpErr, s.UUID) 528 | return 529 | } 530 | logrus.Errorf("Error setting RCPT TO: %v - UUID: %s", err, s.UUID) 531 | return 532 | } 533 | 534 | // Start the DATA command 535 | w, err := client.Data() 536 | if err != nil { 537 | logrus.Errorf("Error initiating email data transfer: %v - UUID: %s", err, s.UUID) 538 | return 539 | } 540 | 541 | // Modify email data 542 | var modifiedEmailData []byte 543 | //modifiedEmailData, _ = []byte(removeEmailHeaders()[]) 544 | modifiedEmailData, _ = removeEmailHeaders(emailData, []string{"DKIM-*", "Authentication-*"}) 545 | if strings.EqualFold(targetAddress, CONFIG.SMTP.PrivateEmail) { 546 | modifiedEmailData, _ = modifyEmailHeaders(modifiedEmailData, formattedSender, "") 547 | headersToAdd := map[string]string{ 548 | "Original-From": s.from, 549 | "Original-To": strings.Join(s.to, ","), 550 | "Original-Server": s.remoteIP, 551 | "Original-Spf-Result": string(s.spfResult), 552 | "Original-Message-Id": s.msgId, 553 | "Message-Id": fmt.Sprintf("<%s@%s>", s.UUID, senderDomain), 554 | "UUID": s.UUID, 555 | } 556 | modifiedEmailData, _ = addEmailHeaders(modifiedEmailData, headersToAdd) 557 | } else { 558 | modifiedEmailData, _ = modifyEmailHeaders(modifiedEmailData, formattedSender, targetAddress) 559 | modifiedEmailData, _ = removeEmailHeaders(modifiedEmailData, headersToRemove) 560 | headersToAdd := map[string]string{ 561 | "Message-Id": fmt.Sprintf("<%s@%s>", s.UUID, senderDomain), 562 | } 563 | modifiedEmailData, _ = addEmailHeaders(modifiedEmailData, headersToAdd) 564 | } 565 | if useDMARC { 566 | var dkimErr error 567 | modifiedEmailData, dkimErr = applyDMARCSignature(modifiedEmailData, formattedSender, senderDomain, s.UUID) 568 | if dkimErr != nil { 569 | logrus.Errorf("Failed to apply DMARC signature: %v - UUID: %s", dkimErr, s.UUID) 570 | // 继续发送邮件,但不使用DMARC签名 571 | } else { 572 | logrus.Infof("DMARC signature applied successfully - UUID: %s", s.UUID) 573 | } 574 | } 575 | 576 | // Write the modified email data to the server 577 | _, err = w.Write(modifiedEmailData) 578 | if err != nil { 579 | logrus.Errorf("Error writing email data: %v - UUID: %s", err, s.UUID) 580 | return 581 | } 582 | 583 | // Close the data writer 584 | err = w.Close() 585 | if err != nil { 586 | logrus.Errorf("Error finalizing email data transfer: %v - UUID: %s", err, s.UUID) 587 | return 588 | } 589 | 590 | // Quit the SMTP session 591 | err = client.Quit() 592 | if err != nil { 593 | logrus.Errorf("Error sending QUIT command: %v - UUID: %s", err, s.UUID) 594 | } 595 | logrus.Infof("Email successfully forwarded to [%s] - UUID: %s", targetAddress, s.UUID) 596 | } 597 | 598 | func tryDialSMTPPlain(smtpServer string, port int) (net.Conn, error) { 599 | dialer := net.Dialer{ 600 | Timeout: 5 * time.Second, // Connection timeout 601 | KeepAlive: 30 * time.Second, // Keep alive interval 602 | } 603 | address := net.JoinHostPort(smtpServer, fmt.Sprintf("%d", port)) 604 | conn, err := dialer.Dial("tcp", address) 605 | if err != nil { 606 | return nil, fmt.Errorf("failed to dial SMTP server on port %d: %v", port, err) 607 | } 608 | logrus.Infof("Successfully connected to SMTP server on port %d without TLS", port) 609 | return conn, nil 610 | } 611 | func getPrimaryContentType(contentType string) string { 612 | // Split the Content-Type by semicolon and return the first part 613 | parts := strings.Split(contentType, ";") 614 | return strings.TrimSpace(parts[0]) 615 | } 616 | func sendWebhook(config WebhookConfig, title, content string, traceid string) (*http.Response, error) { 617 | if !config.Enabled { 618 | return nil, fmt.Errorf("webhook is disabled - TraceID: %s", traceid) 619 | } 620 | var requestBody []byte 621 | var err error 622 | if config.BodyType == "json" { 623 | body := make(map[string]string) 624 | for key, value := range config.Body { 625 | formattedValue := strings.ReplaceAll(value, "{{.Title}}", title) 626 | formattedValue = strings.ReplaceAll(formattedValue, "{{.Content}}", content) 627 | body[key] = formattedValue 628 | } 629 | requestBody, err = json.Marshal(body) 630 | if err != nil { 631 | logrus.Errorf("Failed to marshal JSON body - TraceID: %s, Error: %v", traceid, err) 632 | return nil, err 633 | } 634 | } else if config.BodyType == "form" { 635 | form := url.Values{} 636 | for key, value := range config.Body { 637 | formattedValue := strings.ReplaceAll(value, "{{.Title}}", title) 638 | formattedValue = strings.ReplaceAll(formattedValue, "{{.Content}}", content) 639 | form.Add(key, formattedValue) 640 | } 641 | requestBody = []byte(form.Encode()) 642 | } 643 | req, err := http.NewRequest(config.Method, config.URL, bytes.NewBuffer(requestBody)) 644 | if err != nil { 645 | logrus.Errorf("Failed to create HTTP request - TraceID: %s, Error: %v", traceid, err) 646 | return nil, err 647 | } 648 | for key, value := range config.Headers { 649 | req.Header.Set(key, value) 650 | } 651 | if config.BodyType == "json" { 652 | req.Header.Set("Content-Type", "application/json") 653 | } else if config.BodyType == "form" { 654 | req.Header.Set("Content-Type", "application/x-www-form-urlencoded") 655 | } 656 | client := &http.Client{} 657 | resp, err := client.Do(req) 658 | if err != nil { 659 | logrus.Errorf("Failed to send webhook request - TraceID: %s, Error: %v", traceid, err) 660 | return nil, err 661 | } 662 | logrus.Infof("Webhook response status - TraceID: %s, Status: %s", traceid, resp.Status) 663 | return resp, nil 664 | } 665 | func getFirstMatchingEmail(recipients []string) string { 666 | // Loop through all recipients 667 | for _, recipient := range recipients { 668 | recipientEmail := extractEmails(recipient) 669 | for _, domain := range CONFIG.SMTP.AllowedDomains { 670 | if checkDomain(recipientEmail, domain) { 671 | return recipientEmail 672 | } 673 | } 674 | } 675 | return "" 676 | } 677 | func shouldForwardEmail(recipients []string) bool { 678 | // Loop through all recipients 679 | for _, recipient := range recipients { 680 | recipientEmail := extractEmails(recipient) 681 | for _, domain := range CONFIG.SMTP.AllowedDomains { 682 | if checkDomain(recipientEmail, domain) { 683 | return true // Forward if recipient matches allowed domain 684 | } 685 | } 686 | } 687 | return false // No matching domains, no forwarding 688 | } 689 | 690 | func applyDMARCSignature(emailData []byte, sender, domain, uuid string) ([]byte, error) { 691 | logrus.Infof("开始应用DMARC签名 - 发件人: [%s], 域名: [%s], UUID: %s", sender, domain, uuid) 692 | // 检查是否有DKIM私钥配置 693 | if CONFIG.SMTP.DKIMPrivateKey == "" { 694 | logrus.Errorf("DKIM私钥未配置,无法应用DMARC签名 - UUID: %s", uuid) 695 | return nil, fmt.Errorf("DKIM private key not configured") 696 | } 697 | // 检查DKIM选择器是否配置 698 | if CONFIG.SMTP.DKIMSelector == "" { 699 | logrus.Errorf("DKIM选择器未配置,无法应用DMARC签名 - UUID: %s", uuid) 700 | return nil, fmt.Errorf("DKIM selector not configured") 701 | } 702 | // 解析邮件 703 | logrus.Debugf("解析邮件内容以应用DMARC签名 - UUID: %s", uuid) 704 | msg, err := mail.ReadMessage(bytes.NewReader(emailData)) 705 | if err != nil { 706 | logrus.Errorf("解析邮件失败: %v - UUID: %s", err, uuid) 707 | return nil, fmt.Errorf("failed to parse email: %v", err) 708 | } 709 | // 读取原始邮件头 710 | headers := make(map[string]string) 711 | for k, v := range msg.Header { 712 | headers[k] = strings.Join(v, ", ") 713 | } 714 | logrus.Debugf("成功读取邮件头,准备添加DKIM签名 - UUID: %s", uuid) 715 | // 准备DKIM签名所需的头部 716 | // 这里需要使用第三方库来实现DKIM签名 717 | logrus.Infof("使用域名 [%s] 和选择器 [%s] 生成DKIM签名 - UUID: %s", 718 | domain, CONFIG.SMTP.DKIMSelector, uuid) 719 | 720 | // 生成DKIM签名 721 | dkimSignature, err := generateDKIMSignature(emailData, CONFIG.SMTP.DKIMPrivateKey, CONFIG.SMTP.DKIMSelector, domain) 722 | if err != nil { 723 | logrus.Errorf("生成DKIM签名失败: %v - UUID: %s", err, uuid) 724 | return nil, fmt.Errorf("failed to generate DKIM signature: %v", err) 725 | } 726 | logrus.Debugf("DKIM签名生成成功 - UUID: %s", uuid) 727 | 728 | // 添加DKIM-Signature头 729 | headers["DKIM-Signature"] = dkimSignature 730 | logrus.Debugf("已添加DKIM-Signature头到邮件 - UUID: %s", uuid) 731 | 732 | // 重建邮件内容 733 | var buf bytes.Buffer 734 | for k, v := range headers { 735 | fmt.Fprintf(&buf, "%s: %s\r\n", k, v) 736 | } 737 | buf.WriteString("\r\n") 738 | 739 | // 附加原始邮件正文 740 | body, err := io.ReadAll(msg.Body) 741 | if err != nil { 742 | logrus.Errorf("读取邮件正文失败: %v - UUID: %s", err, uuid) 743 | return nil, err 744 | } 745 | buf.Write(body) 746 | 747 | logrus.Infof("DMARC签名应用完成,邮件已重建 - UUID: %s", uuid) 748 | return buf.Bytes(), nil 749 | } 750 | 751 | func generateDKIMSignature(emailData []byte, privateKey, selector, domain string) (string, error) { 752 | // 记录签名过程开始 753 | logrus.Debugf("开始为域名 [%s] 使用选择器 [%s] 生成DKIM签名", domain, selector) 754 | if len(privateKey) < 10 { 755 | logrus.Warnf("DKIM私钥长度异常短: %d 字符", len(privateKey)) 756 | return "", fmt.Errorf("DKIM私钥长度异常短") 757 | } 758 | // 创建邮件数据的副本,因为签名过程会修改原始数据 759 | emailCopy := make([]byte, len(emailData)) 760 | copy(emailCopy, emailData) 761 | // 创建一个新的 DKIM 签名选项 762 | options := dkim.NewSigOptions() 763 | options.PrivateKey = []byte(privateKey) 764 | options.Domain = domain 765 | options.Selector = selector 766 | options.SignatureExpireIn = 3600 // 签名有效期1小时 767 | options.BodyLength = 0 // 不限制正文长度 768 | options.Headers = []string{"from", "to", "subject", "date", "message-id"} // 要签名的头部 769 | options.AddSignatureTimestamp = true 770 | options.Canonicalization = "relaxed/relaxed" // 使用宽松的规范化方法 771 | 772 | // 直接对邮件数据进行签名 773 | // 注意:Sign函数会直接修改传入的邮件数据,添加DKIM-Signature头 774 | err := dkim.Sign(&emailCopy, options) 775 | if err != nil { 776 | logrus.Errorf("生成DKIM签名失败: %v", err) 777 | return "", fmt.Errorf("failed to generate DKIM signature: %v", err) 778 | } 779 | // 从签名后的邮件中提取DKIM-Signature头 780 | msg, err := mail.ReadMessage(bytes.NewReader(emailCopy)) 781 | if err != nil { 782 | logrus.Errorf("解析签名后的邮件失败: %v", err) 783 | return "", fmt.Errorf("failed to parse signed email: %v", err) 784 | } 785 | dkimSignature := msg.Header.Get("DKIM-Signature") 786 | if dkimSignature == "" { 787 | logrus.Errorf("无法从签名后的邮件中获取DKIM-Signature头") 788 | return "", fmt.Errorf("DKIM-Signature header not found in signed email") 789 | } 790 | // 记录签名成功 791 | if len(dkimSignature) > 30 { 792 | logrus.Debugf("DKIM签名生成成功: %s...", dkimSignature[:30]) 793 | } else { 794 | logrus.Debugf("DKIM签名生成成功: %s", dkimSignature) 795 | } 796 | 797 | return dkimSignature, nil 798 | } 799 | 800 | // 从私钥中提取公钥信息用于DKIM DNS记录 801 | func extractPublicKeyInfo(privateKeyPEM string) (string, error) { 802 | // 解码PEM块 803 | block, _ := pem.Decode([]byte(privateKeyPEM)) 804 | if block == nil { 805 | return "", errors.New("failed to decode PEM block containing private key") 806 | } 807 | // 解析私钥 808 | var privKey *rsa.PrivateKey 809 | var err error 810 | // 尝试PKCS1格式 811 | privKey, err = x509.ParsePKCS1PrivateKey(block.Bytes) 812 | if err != nil { 813 | // 尝试PKCS8格式 814 | key, parseErr := x509.ParsePKCS8PrivateKey(block.Bytes) 815 | if parseErr != nil { 816 | //logrus.Errorf("Failed to parse private key: %v", parseErr) 817 | return "", errors.New("failed to parse private key: not PKCS1 or PKCS8 format") 818 | } 819 | var ok bool 820 | privKey, ok = key.(*rsa.PrivateKey) 821 | if !ok { 822 | return "", errors.New("private key is not RSA type") 823 | } 824 | } 825 | // 序列化公钥 826 | pubKeyBytes, err := x509.MarshalPKIXPublicKey(&privKey.PublicKey) 827 | if err != nil { 828 | return "", errors.New("failed to marshal public key") 829 | } 830 | // Base64编码 831 | pubKeyBase64 := base64.StdEncoding.EncodeToString(pubKeyBytes) 832 | return pubKeyBase64, nil 833 | } 834 | --------------------------------------------------------------------------------