前期

由于 Google 收紧 Suite 的免费政策,我选择抛弃它。可市面上能够替代这么好用的产品的免费政策已经没有多少可以选择。

  • Yandex 360 这个不知道什么时候开始收费,注意是俄文网站,有英文可以选;
  • Zoho 不知道是不是免费政策的问题,用户名限制在7个或以上长度的。听说还会有漏收的问题;
  • 飞书 不知道脑袋哪里敲到,竟然选择试试国内服务。发现里面真是各种坑:如果不介意用飞书 App 倒不是不能用;每个成员必须填写手机号码;创建者只能用手机号码登录,我没有找到用邮箱来登录的方法;注册完每个成员添加后,必须收费才能使用密码正确登录,不知道用手机号码是否能正确登录;IMAP 功能要收费;
  • 阿里云 正常收费服务,600一年;
  • 腾讯云 正常收费服务,700一年,有个免费的标准版,连 https 都不支持;

试完这多,然后一天就没了。浪费生命!

准备

还记得12年我就部署过一套400人用的邮件服务。现在已经有更方便的组合。

软件

  • Docker 个人来说最简单快捷的容器;
  • Docker Compose 同上,保持服务运行;
  • Maddy 支持 IMAP 和 SMTP ,主要是 Go 编写;
  • Rainloop 提供网页操作界面,基本的操作;
  • Nginx 反代工具,可以用 Apache Http 替代

服务器推荐放在国外

知识点:

  • SSL 证书申请,日常自动化 acme.sh 即可;
  • DNS 自己有一个独立的域名,能够操作 DNS 记录,包括A,AAAA,MX,TXT,其他为了保护邮箱还有 SPF,DMARC;
  • Linux 命令行,入门即可;

部署

1. 安装 Docker

curl -fsSL https://get.docker.com -o get-docker.sh
sh get-docker.sh --mirror Aliyun
gpasswd -a <user name> docker # 添加用户到 docker 分组

等待安装完成,重新登录终端一次(刷新用户属性)

2. 安装 docker-compose

现在 docker compose v2.5.0 了,不依赖 Python 。

mkdir $HOME/.docker/cli-plugins # 不同平台可能在不同地方
wget -O $HOME/.docker/cli-plugins/docker-compose https://github.com/docker/compose/releases/download/v2.5.0/docker-compose-linux-x86_64 # 此处的 linux-x86_64 可以选择对应的运行平台
chmod +x $HOME/.docker/cli-plugins/docker-compose

支持的运行平台列表:https://github.com/docker/compose/releases

3. 编写 docker-compose.yml

将配置和数据放在一个目录内,方便长期运行和管理,现在暂定为 /data

mkdir /data/
cd /data/
touch /data/docker-compose.yml
mkdir maddy
mkdir rainloop

将配置填充到 docker-compose.yml

version: "3.9"

services:

  maddy:
    image: foxcpp/maddy:latest
    container_name: maddy
    restart: unless-stopped
    volumes:
      - ./maddy:/data
      - /data/certs/fullchain.pem:/data/local_certs/cert.pem:ro
      - /data/certs/cert.pem:/data/local_certs/key.pem:ro
    ports:
      - 25:25/tcp
      - 465:465/tcp
      - 993:993/tcp

  rainloop:
    image: php:fpm-alpine3.15
    container_name: rainloop
    restart: unless-stopped
    volumes:
      - ./rainloop:/var/www/html
    ports:
      - 127.0.0.1:9000:9000
    logging:
      driver: json-file
      options:
        max-size: "1m"
        max-file: "10"

配置里面的 maddy -> volumes 包括 SSL 证书映射,配置成只读,这里跳过一个知识点,SSL 证书申请。

- /data/certs/fullchain.pem:/data/local_certs/cert.pem:ro
- /data/certs/cert.pem:/data/local_certs/key.pem:ro

4. 配置 Maddy

cd /data
mkdir maddy
cd maddy
touch maddy.conf
touch aliases

配置 maddy.conf 文件,详细配置可以看 Maddy 自建邮件服务

这里我是对外直接提供 25、465、993 端口,而不是经过 nginx 。

# 预设了三个变量,方便后续使用
$(hostname) = example.com
$(primary_domain) = example.com
$(local_domains) = $(primary_domain)

# 如果要使用 nginx 反代,这里可以选择 tls off,但如此一来没法生成 dkim 密钥对
# 在之后检查时日志内会有安全警告,故推荐直接用 maddy 管理
# tls off
#tls file /etc/letsencrypt/live/$(local_domains)/fullchain.pem /etc/letsencrypt/live/$(local_domains)/privkey.pem
tls file /data/local_certs/cert.pem /data/local_certs/key.pem

# 数据用 SQLite3 存储较为简单、轻量
auth.pass_table local_authdb {
    table sql_table {
        driver sqlite3
        dsn credentials.db
        table_name passwords
    }
}

storage.imapsql local_mailboxes {
    driver sqlite3
    dsn imapsql.db
}

# ----------------------------------------------------------------------------
# SMTP endpoints + message routing

hostname $(hostname)

table.chain local_rewrites {
    optional_step regexp "(.+)\+(.+)@(.+)" "$1@$3"
    optional_step static {
        entry postmaster postmaster@$(primary_domain)
    }
    optional_step file /data/aliases
}

msgpipeline local_routing {
    destination postmaster $(local_domains) {
        modify {
            replace_rcpt &local_rewrites
        }

        deliver_to &local_mailboxes
    }

    default_destination {
        reject 550 5.1.1 "User doesn't exist"
    }
}

# smtp 使用 25 号端口发送邮件
smtp tcp://[::]:25 {
    # tls self_signed
    limits {
        # Up to 20 msgs/sec across max. 10 SMTP connections.
        all rate 20 1s
        all concurrency 10
    }

    dmarc yes
    check {
        require_mx_record
        dkim # 若无则不检查
        spf
    }

    source $(local_domains) {
        reject 501 5.1.8 "Use Submission for outgoing SMTP"
    }
    default_source {
        destination postmaster $(local_domains) {
            deliver_to &local_routing
        }
        default_destination {
            reject 550 5.1.1 "User doesn't exist"
        }
    }
}

# 如果使用 nginx 反代则这里监听到本地端口即可 tcp://127.0.0.1:587
# 不使用则邮件客户端以 SSL/TLS 方式直接访问该地址
submission tls://[::]:465 {
    limits {
        # Up to 50 msgs/sec across any amount of SMTP connections.
        all rate 50 1s
    }

    auth &local_authdb

    source $(local_domains) {
        check {
            authorize_sender {
                prepare_email &local_rewrites
                user_to_email identity
            }
        }

        destination postmaster $(local_domains) {
            deliver_to &local_routing
        }
        default_destination {
            modify {
                dkim $(primary_domain) $(local_domains) default
            }
            deliver_to &remote_queue
        }
    }
    default_source {
        reject 501 5.1.8 "Non-local sender domain"
    }
}

target.remote outbound_delivery {
    limits {
        # Up to 20 msgs/sec across max. 10 SMTP connections
        # for each recipient domain.
        destination rate 20 1s
        destination concurrency 10
    }
    mx_auth {
        dane
        mtasts {
            cache fs
            fs_dir mtasts_cache/
        }
        local_policy {
            min_tls_level encrypted
            min_mx_level none
        }
    }
}

target.queue remote_queue {
    target &outbound_delivery

    autogenerated_msg_domain $(primary_domain)
    bounce {
        destination postmaster $(local_domains) {
            deliver_to &local_routing
        }
        default_destination {
            reject 550 5.0.0 "Refusing to send DSNs to non-local addresses"
        }
    }
}

# ----------------------------------------------------------------------------
# IMAP endpoints
# 同上,使用 nginx 反代则改为监听本地端口 tcp://127.0.0.1:143
imap tls://[::]:993 {
    auth &local_authdb
    storage &local_mailboxes
}

5. 配置 Rainloop

Rainloop 是 PHP 项目,为了节省内存,直接用 fpm-alpine 启动即可

cd /data/
mkdir rainloop
cd rainloop
wget -q https://www.rainloop.net/repository/webmail/rainloop-community-latest.zip
7z x rainloop-community-latest.zip # 解压文件到此
ls .
# 应该会多出 data index.php rainloop 两个文件夹和一个文件

6. 启动 docker compose 对应的镜像

cd /data/
docker compose up -d # 等待下载和镜像启动
docker compose ps 
# 应该有如下两个容器启动了
# maddy               "/bin/maddy -config …"   maddy               running             0.0.0.0:25->25/tcp, 0.0.0.0:465->465/tcp, 0.0.0.0:993->993/tcp, :::25->25/tcp, :::465->465/tcp, :::993->993/tcp
# rainloop            "docker-php-entrypoi…"   rainloop            running             127.0.0.1:9000->9000/tcp

7. 配置 nginx

此处只是为了 Rainloop 启动而保留的配置,因为 Maddy 不依赖 Nginx


server {
    server_name mail.example.com;

    listen 80;
    listen [::]:80;
    
    return       301 https://mail.example.com/;
}

server {
    server_name mail.example.com;
    listen 443 ssl http2;
    listen [::]:443 ssl http2;
    ssl_certificate /data/certs/fullchain.pem;
    ssl_certificate_key /data/certs/cert.pem;

    access_log  /var/log/nginx/access_mail.log;
    error_log   /var/log/nginx/error_mail.log;

    root /data/rainloop/;
    index  index.php;

    location ~ \.php$ {
        root           /var/www/html;
        fastcgi_pass   127.0.0.1:9000;
        fastcgi_index  index.php;
        fastcgi_param  SCRIPT_FILENAME  $document_root$fastcgi_script_name;
        include        fastcgi_params;
    }

    location ^~ /data {
    deny all;
    }
}

8. 配置 DNS

这里没有先后顺序,只要配置完成即可

第一条 MX 记录,优先级为 10 (这里一个服务器的情况下,这里的大小没所谓),TTL 为 300 (也是随意,稳定了调整为 3600 或者 24h 都行)。

example.com.		300	IN	MX  10 mx.example.com.

第二条 TXT 类型的 SPF,此处为了安全,除了 mx 外不允许通行,详细资料可以上网搜索下,相对简单就不做介绍。

example.com.		600	IN	TXT	"v=spf1 mx  ~all"

第三条 TXT 类型的 DMARC ,此处是支持退信和错误处理的收件箱

_dmarc.example.com.	300	IN	TXT	"v=DMARC1; p=quarantine; ruf=mailto:[email protected]"

9. 启动

cd /data
dc up -d 

现在即可通过网页和 IMAP 、 SMTP 访问到邮件服务器

现在可以试试通过网页访问 https://mail.example.com 来访问,并且发送邮件

更多

DNS 还可以配置更多,包括 DKIM 的项

当然最后需要测试下 Mail Tester 这里每天可以测试 3 封,过了,当然要付费。

感谢

参考了以下文章,感谢作者的付出。