把 JumpServer 对接 AD 跑通之后,继续把 Mailu 接进同一套 AD 环境。目标很明确:

Mailu 保持自己的邮件服务能力

AD 作为统一的用户来源

从指定 OU 自动读取启用用户

自动在 Mailu 中创建对应邮箱

由于Mailu 不太像 JumpServer 那样,直接填 LDAP/AD 参数就完成原生域认证,所以最终的方案不是Mailu 直接用 AD 密码认证登录,而是AD 管人,Mailu 自动建邮箱

这套方式依赖两部分能力:

  1. AD 的 LDAPS 查询能力
  2. Mailu 的 REST API 创建用户能力。
  3. Mailu 的 /api/v1/user、/api/v1/user/{email} 等接口可用于查询和创建邮箱用户。

本文环境如下:

AD 域:ad.yxwa.info

域控:DC01.ad.yxwa.info

Mailu 域:yxwa.info

同步范围:OU=class,DC=ad,DC=yxwa,DC=info

邮箱命名规则:sAMAccountName@yxwa.info

例如:

AD 用户 test1 → Mailu 邮箱 test1@yxwa.info

AD 用户 test2 → Mailu 邮箱 test2@yxwa.info

前提条件有两个已经确认:

AD 通过 LDAPS 正常工作,域控证书有效。

Mailu 已启用 API,且 API=true、WEB_API=/api,Swagger 可从 /api/v1/swagger.json 读取。

先让 Mailu 服务器信任 AD 的 LDAPS 证书

因为 AD 用的是内部 CA 证书,Mailu 服务器默认不信任,需要先把 CA 链加进去。

先合并 CA 链

cat /tmp/intermediate_ca.crt /tmp/root_ca.crt > /tmp/ad_ca_chain.pem

安装到系统信任

mkdir -p /usr/local/share/ca-certificates/ad
cp /tmp/ad_ca_chain.pem /usr/local/share/ca-certificates/ad/ad_ca_chain.crt
update-ca-certificates

验证 LDAPS 连通性

openssl s_client -connect DC01.ad.yxwa.info:636 -servername DC01.ad.yxwa.info

如果能连通,再继续验证 LDAP 查询。


ldapsearch -x -H ldaps://DC01.ad.yxwa.info:636 \
-D "administrator@ad.yxwa.info" \
-W \
-b "OU=class,DC=ad,DC=yxwa,DC=info" \
"(&(objectClass=user)(objectCategory=person))" sAMAccountName userPrincipalName

如果返回类似下面的内容,说明 AD 查询正常:





我们最终就是基于 sAMAccounte来生成 Mailu 邮箱名。


验证 Mailu API

Mailu 已经提供了标准 API,Swagger 中可以看到:

GET /domain,GET /user,GET /user/{email},POST /user

这些接口都在 /api/v1 下。

查看 API Token

curl -s -X POST \
-H "Authorization: Bearer <MAILU_API_TOKEN>" \
-H "Content-Type: application/json" \
-d '{
"email": "test2@yxwa.info",
"raw_password": "<INITIAL_PASSWORD>",
"enabled": true,
"enable_imap": true,
"enable_pop": true,
"quota_bytes": 2000000000
}' \
https://mailu.yxwa.info/api/v1/user

Mailu 的 POST /user 接口要求至少提供 email 和 raw_password。

查看域名列表

curl -s \
-H "Authorization: Bearer <MAILU_API_TOKEN>" \
https://mailu.yxwa.info/api/v1/domain

查看用户列表

curl -s \
-H "Authorization: Bearer <MAILU_API_TOKEN>" \
https://mailu.yxwa.info/api/v1/user

手工创建一个用户测试

curl -s -X POST \
-H "Authorization: Bearer <MAILU_API_TOKEN>" \
-H "Content-Type: application/json" \
-d '{
"email": "test2@yxwa.info",
"raw_password": "<INITIAL_PASSWORD>",
"enabled": true,
"enable_imap": true,
"enable_pop": true,
"quota_bytes": 2000000000
}' \
https://mailu.yxwa.info/api/v1/user

Mailu 的 POST /user 接口要求至少提供 email 和 raw_password。


无交互同步脚本

最终脚本思路很简单:

  1. 从 AD 指定 OU 查询启用用户
  2. 读取每个用户的 sAMAccountName
  3. 拼成 用户名@yxwa.info
  4. 先调用 GET /user/{email} 检查是否存在
  5. 不存在则调用 POST /user 创建
  6. 把日志收敛为只记录创建成功和报错

1. AD 密码文件

注意:密码文件不能带末尾换行,否则 ldapsearch -y 可能会认证失败。

mkdir -p /root/.secrets
printf %s '<AD_BIND_PASSWORD>' > /root/.secrets/ad_bind_password
chmod 600 /root/.secrets/ad_bind_password

2. Python 脚本

保存为 root/ad_to_mailu_sync.py:

#!/usr/bin/env python3
import os
import json
import subprocess
import urllib.request
import urllib.error
import ssl
import sys
from datetime import datetime

# ========= AD 配置 =========
LDAP_URL = "ldaps://DC01.ad.yxwa.info:636"
LDAP_BIND_DN = "administrator@ad.yxwa.info"
LDAP_PASSWORD_FILE = "/root/.secrets/ad_bind_password"
LDAP_BASE_DN = "OU=class,DC=ad,DC=yxwa,DC=info"

# ========= Mailu 配置 =========
MAIL_DOMAIN = "yxwa.info"
MAILU_API_BASE = "https://mailu.yxwa.info/api/v1"

# ========= 环境变量 =========
MAILU_API_TOKEN = os.environ["MAILU_API_TOKEN"]
INITIAL_PASSWORD = os.environ["INITIAL_PASSWORD"]
DRY_RUN = os.environ.get("DRY_RUN", "false").lower() == "true"

# true 时会记录跳过/统计等详细日志
VERBOSE = os.environ.get("VERBOSE", "false").lower() == "true"

# 业务日志文件
LOG_FILE = os.environ.get("LOG_FILE", "/var/log/ad-to-mailu-sync.log")

SSL_CONTEXT = ssl.create_default_context()


def write_log(msg: str, force: bool = False):
if not force and not VERBOSE:
return
now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
line = f"[{now}] {msg}\n"
with open(LOG_FILE, "a", encoding="utf-8") as f:
f.write(line)


def run_ldapsearch() -> str:
cmd = [
"ldapsearch",
"-LLL",
"-x",
"-H", LDAP_URL,
"-D", LDAP_BIND_DN,
"-y", LDAP_PASSWORD_FILE,
"-b", LDAP_BASE_DN,
"(&(objectClass=user)(objectCategory=person)"
"(!(userAccountControl:1.2.840.113556.1.4.803:=2)))",
"sAMAccountName",
]

result = subprocess.run(cmd, capture_output=True, text=True)

if result.returncode != 0:
raise RuntimeError(f"ldapsearch 执行失败: {result.stderr.strip()}")

return result.stdout


def parse_samaccountnames(ldap_output: str):
users = []
for line in ldap_output.splitlines():
line = line.strip()
if line.lower().startswith("samaccountname:"):
username = line.split(":", 1)[1].strip()
if username:
users.append(username)
return users


def mailu_request(method: str, path: str, payload=None):
url = f"{MAILU_API_BASE}{path}"
data = None
headers = {
"Authorization": f"Bearer {MAILU_API_TOKEN}",
}

if payload is not None:
data = json.dumps(payload).encode("utf-8")
headers["Content-Type"] = "application/json"

req = urllib.request.Request(url, data=data, headers=headers, method=method)

try:
with urllib.request.urlopen(req, context=SSL_CONTEXT, timeout=20) as resp:
body = resp.read().decode("utf-8")
return resp.status, body
except urllib.error.HTTPError as e:
body = e.read().decode("utf-8", errors="ignore")
return e.code, body
except Exception as e:
return 599, str(e)


def user_exists(email: str) -> bool:
status, _ = mailu_request("GET", f"/user/{email}")
return status == 200


def create_user(email: str):
payload = {
"email": email,
"raw_password": INITIAL_PASSWORD,
"enabled": True,
"enable_imap": True,
"enable_pop": True,
"quota_bytes": 2000000000
}
return mailu_request("POST", "/user", payload)


def main():
try:
ldap_output = run_ldapsearch()
usernames = parse_samaccountnames(ldap_output)

if not usernames:
write_log("没有查到任何可同步用户。", force=True)
return 0

created = 0
skipped = 0
failed = 0
preview = 0

for username in usernames:
email = f"{username}@{MAIL_DOMAIN}"

if user_exists(email):
skipped += 1
write_log(f"[跳过] 已存在: {email}")
continue

if DRY_RUN:
preview += 1
write_log(f"[预览] 将创建: {email}", force=True)
continue

status, body = create_user(email)
if status == 200:
created += 1
write_log(f"[创建成功] {email}", force=True)
else:
failed += 1
write_log(f"[创建失败] {email} -> HTTP {status} -> {body}", force=True)

if VERBOSE:
if DRY_RUN:
write_log(f"执行完成(预览模式): 预览 {preview} 个, 跳过 {skipped} 个", force=True)
else:
write_log(f"执行完成: 创建 {created} 个, 跳过 {skipped} 个, 失败 {failed} 个", force=True)

return 0 if failed == 0 else 1

except Exception as e:
write_log(f"脚本异常: {e}", force=True)
return 1


if __name__ == "__main__":
sys.exit(main())

3. 手工执行

预览模式:

export MAILU_API_TOKEN='<MAILU_API_TOKEN>'
export INITIAL_PASSWORD='<INITIAL_PASSWORD>'
export DRY_RUN='true'
python3 /root/ad_to_mailu_sync.py

正式模式:

export MAILU_API_TOKEN='<MAILU_API_TOKEN>'
export INITIAL_PASSWORD='<INITIAL_PASSWORD>'
export DRY_RUN='false'
python3 /root/ad_to_mailu_sync.py

实测效果是:已存在用户会跳过,不存在的例如 test3@yxwa.info、test4@yxwa.info 会被成功创建。


做成自动同步

由于需要持续同步,可以挂到 systemd timer。

先创建并赋权日志读取目录文件

touch /var/log/ad-to-mailu-sync.log
chmod 600 /var/log/ad-to-mailu-sync.log

1. service 文件

/etc/systemd/system/ad-to-mailu-sync.service

[Unit]
Description=AD to Mailu sync
After=network-online.target
Wants=network-online.target

[Service]
Type=oneshot
User=root
Environment="MAILU_API_TOKEN=<MAILU_API_TOKEN>"
Environment="INITIAL_PASSWORD=<INITIAL_PASSWORD>"
Environment="DRY_RUN=false"
Environment="VERBOSE=false"
Environment="LOG_FILE=/var/log/ad-to-mailu-sync.log"
ExecStart=/usr/bin/python3 /root/ad_to_mailu_sync.py
StandardOutput=null
StandardError=null

2. timer 文件

/etc/systemd/system/ad-to-mailu-sync.timer

[Unit]
Description=Run AD to Mailu sync every 30 seconds[Timer]
OnBootSec=30s
OnUnitActiveSec=30s
AccuracySec=1s
Unit=ad-to-mailu-sync.service[Install]
WantedBy=timers.target

3. 启动

systemctl daemon-reload
systemctl enable --now ad-to-mailu-sync.timer

如果想更高频,也可以把 OnUnitActiveSec=30s 改成 5s。

他的实际效果会变成这样


这套方案的关键点其实就三件事:让 Mailu 所在服务器信任 AD 的 LDAPS 证书用 LDAP 从指定 OU 获取启用用户调用 Mailu API 自动创建邮箱

相比直接让 Mailu 原生接管 AD 登录,这种方式更稳,也更容易渐进式落地:先打通同步创建邮箱,再考虑更复杂的统一认证方案。

此作者没有提供个人介绍。
最后更新于 2026-04-05