标签 iredmail 下的文章

批量设置域名 dkim 脚本 - iRedMail - dkim_setup.py - ptyhon - debian

环境:debian 12.10 ,iRedMail - 1.7.3
目标:批量配置域名 dkim

步骤:

  1. 将 dkim_setup.py 及 domains.txt 上传到邮局服务器文件系统里,如 /root
  2. python3 dkim_setup.py # 执行 dkim_setup.py 脚本。脚本会从 domains.txt (可自行创建,每行一个域名) 里逐行读取邮件域名,创建 dkim 文件、将 相应的域名和 key 追加到 /etc/amavis/conf.d/50-user 文件内容中。脚本每次执行前,会对当前的 /etc/amavis/conf.d/50-user 复制备份,预防出错后需要还原使用,最后脚本会将域名及对应的解析值追加到 dkim_dns_records.json ,方便您解析域名
  3. amavisd testkey # 如需验证 dkim 的查询结果,可执行此命令

如需手工操作:

  1. amavisd-new genrsa /var/lib/dkim/iredmail.demo.anqun.org.pem 2048 # 为邮件域名 iredmail.demo.anqun.org 生成数字签名文件
  2. chown amavis:amavis amavisd genrsa /var/lib/dkim/iredmail.demo.anqun.org.pem # 更改签名文件属主
  3. chmod 0400 /var/lib/dkim/iredmail.demo.anqun.org.pem # 更改签名权限
  4. 编辑 /etc/amavis/conf.d/50-user 文件内容,将签名的邮件域名和文件等追加进去

    # Add dkim_key here.
    dkim_key('iredmail.demo.anqun.org', 'dkim', '/var/lib/dkim/iredmail.demo.anqun.org.pem');
    
    @dkim_signature_options_bysender_maps = ({
     # 'd' defaults to a domain of an author/sender address,
     # 's' defaults to whatever selector is offered by a matching key
    
     # Per-domain dkim key
     #"domain.com"  => { d => "domain.com", a => 'rsa-sha256', ttl => 10*24*3600 },
    
     # catch-all (one dkim key for all domains)
     '.' => {d => 'iredmail.demo.anqun.org',
             a => 'rsa-sha256',
             c => 'relaxed/simple',
             ttl => 30*24*3600 },
    });

dkim_setup.py 的文件内容:

#!/usr/bin/env python3

import subprocess
import os
import datetime
import json

AMAVIS_CONFIG_FILE = "/etc/amavis/conf.d/50-user"
DKIM_BASE_PATH = "/var/lib/dkim"
DKIM_KEY_SIZE = 2048  # Or 2048, if desired
AMAVIS_COMMAND = "amavisd"  # Changed from amavisd-new
DNS_OUTPUT_FILE = "dkim_dns_records.json"

def generate_dkim_key(domain):
    """Generates a DKIM key for the given domain."""
    key_path = os.path.join(DKIM_BASE_PATH, f"{domain}.pem")
    command = [AMAVIS_COMMAND, "genrsa", key_path, str(DKIM_KEY_SIZE)]
    try:
        subprocess.run(command, check=True)
        subprocess.run(["chown", "amavis:amavis", key_path], check=True)
        subprocess.run(["chmod", "0400", key_path], check=True)
        print(f"DKIM key generated for {domain} at {key_path}")
        return key_path
    except subprocess.CalledProcessError as e:
        print(f"Error generating DKIM key for {domain}: {e}")
        return None

def get_dkim_public_key(key_path):
    """Extracts the DKIM public key from the .pem file using openssl."""
    try:
        command = ["openssl", "rsa", "-in", key_path, "-pubout", "-outform", "PEM"]
        process = subprocess.run(command, capture_output=True, text=True, check=True)
        public_key = process.stdout.strip()

        # Remove the BEGIN and END PUBLIC KEY lines and any newlines
        public_key = public_key.replace("-----BEGIN PUBLIC KEY-----", "")
        public_key = public_key.replace("-----END PUBLIC KEY-----", "")
        public_key = public_key.replace("\n", "")

        return public_key
    except subprocess.CalledProcessError as e:
        print(f"Error extracting DKIM public key using openssl: {e}")
        print(f"Stderr: {e.stderr}")  # Print stderr for more details
        return None
    except Exception as e:
        print(f"Error extracting DKIM public key: {e}")
        return None


def domain_exists(domain):
    """Checks if the domain is already configured in Amavis."""
    try:
        with open(AMAVIS_CONFIG_FILE, "r") as f:
            config_content = f.read()
        return domain in config_content
    except FileNotFoundError:
        print(f"Error: Amavis config file not found at {AMAVIS_CONFIG_FILE}")
        return False

def backup_amavis_config():
    """Backs up the Amavis configuration file with a timestamp."""
    timestamp = datetime.datetime.now().strftime("%Y.%m.%d.%H.%M.%S")
    backup_file = f"{AMAVIS_CONFIG_FILE}.{timestamp}"
    try:
        subprocess.run(["cp", AMAVIS_CONFIG_FILE, backup_file], check=True)
        print(f"Amavis config backed up to {backup_file}")
        return True
    except subprocess.CalledProcessError as e:
        print(f"Error backing up Amavis config: {e}")
        return False

def update_amavis_config(domain, key_path):
    """Updates the Amavis configuration file with the DKIM settings."""
    try:
        with open(AMAVIS_CONFIG_FILE, "r") as f:
            config_lines = f.readlines()

        dkim_key_insert_point = None
        start_index = None
        end_index = None
        last_entry_index = None

        for i, line in enumerate(config_lines):
            if "dkim_key(" in line:
                dkim_key_insert_point = i + 1

            if "@dkim_signature_options_bysender_maps = (" in line:
                start_index = i

            if start_index is not None and "=>" in line:
                # 检查是否是一个 domain entry 的开始
                if line.lstrip().startswith('"') or line.lstrip().startswith("'"):
                    last_entry_index = i

            if "});" in line and start_index is not None:
                end_index = i
                break

        if dkim_key_insert_point is None:
            print("Could not find insertion point for dkim_key.")
            return False

        if start_index is None or end_index is None:
            print("Could not find signature options structure.")
            return False

        # 如果没有找到任何已有的 domain entry,则默认插入在 start_index + 1
        insert_sig_index = last_entry_index + 1 if last_entry_index is not None else start_index + 1

        # 构造新行
        new_dkim_key_line = f"dkim_key('{domain}', 'dkim', '{key_path}');\n"
        new_dkim_sig_line = f'    "{domain}" => {{ d => "{domain}", a => \'rsa-sha256\', ttl => 10*24*3600 }},\n'

        # 插入 dkim_key
        config_lines.insert(dkim_key_insert_point, new_dkim_key_line)

        # 如果不是第一个条目,检查前一行是否有逗号
        if last_entry_index is not None:
            prev_line = config_lines[insert_sig_index - 1]
            if not prev_line.strip().endswith(','):
                config_lines[insert_sig_index - 1] = prev_line.rstrip('\n') + ',\n'

        # 插入新的签名选项
        config_lines.insert(insert_sig_index, new_dkim_sig_line)

        # 写回文件
        with open(AMAVIS_CONFIG_FILE, "w") as f:
            f.writelines(config_lines)

        print(f"Amavis config updated for {domain}")
        return True

    except FileNotFoundError:
        print(f"Error: Amavis config file not found at {AMAVIS_CONFIG_FILE}")
        return False
    except Exception as e:
        print(f"Error updating Amavis config: {e}")
        return False

def restart_amavis():
    """Restarts the Amavis service."""
    try:
        subprocess.run(["systemctl", "restart", "amavis"], check=True)  # Corrected command
        print("Amavis service restarted.")
        return True
    except subprocess.CalledProcessError as e:
        print(f"Error restarting Amavis: {e}")
        return False

def write_dns_record(domain, public_key):
    """Writes the DNS record to a JSON file."""
    dns_record_name = f"dkim._domainkey.{domain}"
    # Construct the TXT record value
    txt_record_value = f"v=DKIM1; p={public_key}"
    data = {dns_record_name: txt_record_value}

    try:
        # Check if the file exists and load existing data
        if os.path.exists(DNS_OUTPUT_FILE):
            with open(DNS_OUTPUT_FILE, "r") as f:
                try:
                    existing_data = json.load(f)
                except json.JSONDecodeError:
                    existing_data = {}  # Handle empty or corrupted JSON file
        else:
            existing_data = {}

        # Update with the new record
        existing_data.update(data)

        # Write back to the file
        with open(DNS_OUTPUT_FILE, "w") as f:
            json.dump(existing_data, f, indent=4, ensure_ascii=False)  # indent for readability, disable ASCII escaping

        print(f"DNS record written to {DNS_OUTPUT_FILE}")

    except Exception as e:
        print(f"Error writing DNS record to file: {e}")

if __name__ == "__main__":
    import sys

    # Backup Amavis config *before* processing any domains
    if not backup_amavis_config():
        print("Failed to backup Amavis config.  Aborting.")
        sys.exit(1)

    try:
        with open("domains.txt", "r") as f:
            domains = [line.strip() for line in f.readlines()]
    except FileNotFoundError:
        print("Error: domains.txt not found")
        sys.exit(1)


    for domain in domains:
        print(f"Processing domain: {domain}")

        if domain_exists(domain):
            print(f"Domain {domain} already exists in Amavis config. Skipping.")
            continue



        key_path = generate_dkim_key(domain)
        if key_path:
            if update_amavis_config(domain, key_path):
                public_key = get_dkim_public_key(key_path)
                if public_key:
                    write_dns_record(domain, public_key)
                else:
                    print(f"Failed to get public key for {domain}")
            else:
                print(f"Failed to update amavis config for {domain}")

    if domains: #Only restart if there was at least one domain to process
        restart_amavis()

演示视频:https://www.bilibili.com/video/BV1Jj7fztEN3/

参考:

为 iredmail 1.7.1 安装 z-push 2.7.5

环境:Ubunt 24.04,iredmail 1.7.1 ,安装时,勾选了 SOGo 组件,因为需要使用它的 CalDAV 和 CardDAV 服务

步骤:

  1. apt install php-imap php-curl libawl-php php-curl php-xml php-ldap php-soap php-mbstring php-intl # 安装 php 扩展
  2. mkdir /usr/local/lib/z-push/ /var/log/z-push /var/lib/z-push # 创建 z-push 的相关目录
  3. chown www-data:www-data /var/log/z-push /var/lib/z-push # 目录给 web 用户权限
  4. wget https://github.com/Z-Hub/Z-Push/archive/refs/tags/2.7.5.tar.gz # 下载 z-push 文件
  5. cp -a src/* /usr/local/lib/z-push/ # 解压后,将得到的 src 目录里的文件,复制
  6. chown -R www-data:www-data /usr/local/lib/z-push/ # 给 web 用户权限
  7. vi /etc/nginx/templates/sogo.tmpl # 修改 sogo.tmpl 的文件内容,注释或删除原 SOGo 的 Microsoft-Server-ActiveSync 内容,用新的 z-push 替换:

    location ^~ /Microsoft-Server-ActiveSync {
     proxy_pass http://127.0.0.1:20000/SOGo/Microsoft-Server-ActiveSync;
    
     proxy_connect_timeout 3540;
     proxy_send_timeout 3540;
     proxy_read_timeout 3540;
    
     proxy_busy_buffers_size   64k;
     proxy_buffers             8 64k;
     proxy_buffer_size         64k;
    }
    
    location ^~ /SOGo/Microsoft-Server-ActiveSync {
     proxy_pass http://127.0.0.1:20000/SOGo/Microsoft-Server-ActiveSync;
    
     proxy_connect_timeout 3540;
     proxy_send_timeout 3540;
     proxy_read_timeout 3540;
    
     proxy_busy_buffers_size   64k;
     proxy_buffers             8 64k;
     proxy_buffer_size         64k;
    }

    新的:

    location /Microsoft-Server-ActiveSync {
             include /etc/nginx/fastcgi_params;
             fastcgi_param SCRIPT_FILENAME /usr/local/lib/z-push/index.php;
             fastcgi_read_timeout 630;
             include /etc/nginx/templates/fastcgi_php.tmpl;
             client_max_body_size 128M;
     }
     location ~* ^/autodiscover/autodiscover.xml$ {
             include /etc/nginx/fastcgi_params;
             fastcgi_param SCRIPT_FILENAME /usr/local/lib/z-push/autodiscover/autodiscover.php;
             include /etc/nginx/templates/fastcgi_php.tmpl;
    }
  8. systemctl restart nginx # 重启 nginx 服务
  9. vi /etc/php/8.3/fpm/php.ini # 调整 php.ini 的内容,启用 parse_ini_file 函数
  10. systemctl restart php8.3-fpm.service # 重启 php-fpm 服务
  11. vi /usr/local/lib/z-push/config.php # 编辑配置文件,其中两个需要注意的, TIMEZONE 和 BACKEND_PROVIDER 。本例,是分别设置为 上海时区 Asia/Shanghai 和 BackendCombined 即,后端数据存储是 混搭模式
  12. vi /usr/local/lib/z-push/backend/combined/config.php # 编辑混搭模式里的配置文件内容,将其中罗列的类型精简。如本例,只使用 BackendIMAP , BackendCardDAV 和 BackendCalDAV 。同时,要将目录的处理类型指定,避免出现指定不存在的 z 类型

     public static function GetBackendCombinedConfig() {
         //use a function for it because php does not allow
         //assigning variables to the class members (expecting T_STRING)
         return array(
             //the order in which the backends are loaded.
             //login only succeeds if all backend return true on login
             //sending mail: the mail is sent with first backend that is able to send the mail
             'backends' => array(
                 'i' => array(
                     'name' => 'BackendIMAP',
                 ),
                 'd' => array(
                     'name' => 'BackendCardDAV',
                 ),
                 'c' => array(
                     'name' => 'BackendCalDAV',
                 ),
             ),
             'delimiter' => '/',
             //force one type of folder to one backend
             //it must match one of the above defined backends
             'folderbackend' => array(
                 SYNC_FOLDER_TYPE_INBOX => 'i',
                 SYNC_FOLDER_TYPE_DRAFTS => 'i',
                 SYNC_FOLDER_TYPE_WASTEBASKET => 'i',
                 SYNC_FOLDER_TYPE_SENTMAIL => 'i',
                 SYNC_FOLDER_TYPE_OUTBOX => 'i',
                 SYNC_FOLDER_TYPE_TASK => 'c',
                 SYNC_FOLDER_TYPE_APPOINTMENT => 'c',
                 SYNC_FOLDER_TYPE_CONTACT => 'd',
                 SYNC_FOLDER_TYPE_NOTE => 'c',
                 SYNC_FOLDER_TYPE_JOURNAL => 'c',
                 SYNC_FOLDER_TYPE_OTHER => 'i',
                 SYNC_FOLDER_TYPE_UNKNOWN => 'i',
             ),
             //creating a new folder in the root folder should create a folder in one backend
             'rootcreatefolderbackend' => 'i',
         );
     }
  13. vi /usr/local/lib/z-push/backend/imap/config.php # 配置 imap 的连接配置文件内容。本例是将 IMAP_PORT 设置为 993 端口;IMAP_OPTIONS 设置为 /ssl/novalidate-cert ,即使用 SSL 加密连接,不验证证书;IMAP_FOLDER_CONFIGURED 设置为 true
  14. vi /usr/local/lib/z-push/backend/caldav/config.php # 配置 caldav 的连接配置文件内容。CALDAV_SERVER 填写完整的主机名,如 mail.example.com ;CALDAV_PATH 填写 /SOGo/dav/%u/Calendar/ ; CALDAV_PERSONAL 填写 /SOGo/dav/%u/Calendar/personal/ ;CALDAV_SUPPORTS_SYNC 可设置为 true
  15. vi /usr/local/lib/z-push/backend/carddav/config.php # 配置 carddav 的连接配置文件内容。CARDDAV_PATH 设置为 /SOGo/dav/%u/Contacts/ ; CARDDAV_DEFAULT_PATH 设置为 /SOGo/dav/%u/Contacts/personal/ ;注释掉 CARDDAV_GAL_PATH 和 CARDDAV_GAL_MIN_LENGTH 两行
  16. 在浏览器里访问 https://mail.example.com/Microsoft-Server-ActiveSync ,应该会弹框,需入邮箱地址和密码,能成功登录
  17. 在 Nine Mail app 里,使用兼容 Exchange ActiveSync 的账户类型添加,能发邮件、创建日历事件 和 联系人,并且能在 SOGo Webmail 里看到新建的内容
  18. 如有报错,请查看 /var/log/z-push/z-push-error.log 日志内容

参考:

iredmail - 注册页 - register - 简单制作

需求:有一个简单的 web 表单,让未登录的访客 注册 邮箱
思路:iredadmin 里,有 Create 的类,照它样子,复制出一个 Register 类来;同样,模仿着添加好 web 访问的路径 和 html 表单

环境:debian 12 里安装好的 iredmail - 1.7.1 SQL 版本

请注意,我不会 Python ,这是我在 Poe GPT 帮助下,简单制作的,仅用于测试。

步骤:

文件内容:iRedAdmin-2.6/controllers/sql/urls.py 中,添加 访问路径 :

'/register', 'controllers.sql.basic.Register',

文件内容:iRedAdmin-2.6/controllers/sql/basic.py 中,添加 Register 的类 :

from libs import iredpwd
from libs.sqllib import general as sql_lib_general
class Register:
    def GET(self):
        available_domains = ['iredmail.demo.anqun.org', 'imap.demo.2xinxian.top', 'example.net']  # 示例域名列表
        form = web.input()
        return web.render(
            'register.html',
            available_domains=available_domains,
            msg=form.get('msg'),
        )

    def POST(self):
        form = web.input()

        domain = form.get('domainName', '').strip().lower()
        username = form.get('username', '').strip().lower()
        newpw = form.get('newpw', '').strip()
        confirmpw = form.get('confirmpw', '').strip()
        cn = form.get('cn', '').strip()  # 获取可选的显示名称

        if not username or not newpw or not confirmpw or not domain:
            return web.seeother(f'/register?msg=INVALID_INPUT')

        if newpw != confirmpw:
            return web.seeother(f'/register?msg=PASSWORDS_DO_NOT_MATCH')

        # 生成密码哈希
        pwscheme = 'SSHA512'
        passwd_hash = iredpwd.generate_password_hash(newpw, pwscheme=pwscheme)

        result = self.add_user(domain, username, passwd_hash, cn)  # 将 cn 传递给 add_user

        if result[0]:
            return web.seeother(f'/register?msg=CREATED')
        else:
            return web.seeother(f'/register?msg={web.urlquote(result[1])}')

    def add_user(self, domain, username, password, cn):
        mail = f"{username}@{domain}"

        if sql_lib_general.is_email_exists(mail):
            return (False, 'ALREADY_EXISTS')

        record = {
            'domain': domain,
            'username': mail,
            'password': password,
            'active': 1,
            'name': cn
        }

        try:
            _wrap = SQLWrap()
            conn = _wrap.conn
            conn.insert('mailbox', **record)
            return (True, )
        except Exception as e:
            return (False, repr(e))

创建模板文件 iRedAdmin-2.6/templates/default/register.html

{% extends "layout.html" %}

{% block title %}{{ _('Add mail user') }}{% endblock title %}
{% block navlinks_create %}class="active"{% endblock %}

{% block main %}
{# Show system message #}
{% if msg %}
    {% if msg.startswith('PW_') %}
        {% set _pw_errors = msg.split(',') %}
        {% for _err in _pw_errors %}
            {{ user_msg_handler(_err) }}
        {% endfor %}
    {% else %}
    {#        {{ user_msg_handler(msg) }} #}
        <p>{{ msg }}</p>
    {% endif %}
{% endif %}

    <div class="content-box">
        <div class="box-body">
            <div class="box-header clear">
                <ul class="tabs clear">
                    <li class="active"><a href="#user_add"><i class="fa fa-plus"></i> {{ _('User') }}</a>
                </ul>

                <h2>{{ _('Add mail user') }}</h2>
            </div>

            <div id="user_add" class="box-wrap clear">
                <form name="form_add_user" method="post" action="{{ctx.homepath}}/register">
                    <div class="form-field clear">
                        <h4 class="size-250 fl-space">{{ _('Mail Domain') }} <span class="required">*</span></h4>
                        <span class="clean-padding">
                            <select name="domainName" id="domainSelect" required>
                                {% for domain in available_domains %}
                                    <option value="{{ domain }}">{{ domain }}</option>
                                {% endfor %}
                            </select>
                        </span>
                    </div>
                
                    <div class="form-field clear">
                        <h4 class="size-250 fl-space">{{ _('Mail Address') }} <span class="required">*</span></h4>
                        <span class="clean-padding">
                            <input type="text" size="35" name="username" value="" autocomplete="off" class="text fl-space" required />@
                        </span>
                    </div>
                
                    <div class="form-field clear">
                        <h4 class="size-250 fl-space">{{ _('Password') }} <span class="required">*</span></h4>
                        <span class="clean-padding">
                            <input type="password" name="newpw" required class="text fl-space" />
                        </span>
                    </div>
                    
                    <div class="form-field clear">
                        <h4 class="size-250 fl-space">{{ _('Confirm Password') }} <span class="required">*</span></h4>
                        <span class="clean-padding">
                            <input type="password" name="confirmpw" required class="text fl-space" />
                        </span>
                    </div>

                    <div class="form-field clear">
                        <h4 class="size-250 fl-space">{{ _('Display Name (Optional)') }}</h4>
                        <span class="clean-padding">
                            <input type="text" name="cn" value="" autocomplete="off" class="text fl-space" />
                        </span>
                    </div>                    
                
                    <div class="form-field clear">
                        <span>
                            <input type="submit" name="submit_add_user" value="{{ _('Add') }}" class="button green"/>
                        </span>
                    </div>
                </form>
            </div>{# -- End box-wrap -- #}
        </div>{# -- End content-box -- #}
    </div>{# -- End box-body -- #}
{% endblock main %}

iredmail 自定义注册页 表单

iredmail 自助注册邮箱成功

iredmail 自助注册的邮箱,成功登录到 Webmail

参考:

在 iredmail 1.7.1 - roundcube 1.6.8 里安装 kolab_2fa 3.5.11 插件

环境和已下载的文件,请看上一篇。继续安装插件 kolab_2fa 。

步骤:

  1. cd /opt/www/roundcubemail/plugins/ # 转到 roundcube 插件目录
  2. cp -a /opt/www/roundcubemail-plugins-kolab/plugins/kolab_2fa ./ # 复制 tasklist 插件文件
  3. cp kolab_2fa/config.inc.php.dist kolab_2fa/config.inc.php # 创建配置文件
  4. vi kolab_2fa/config.inc.php # 创建配置文件
  5. cd /opt/www/roundcubemail # 转到 roundcube 目录
  6. sudo -u www-data php /opt/www/composer.phar require "spomky-labs/otphp" "~10.0.3" # 安装 otphp
  7. sudo -u www-data php /opt/www/composer.phar require "endroid/qr-code" "~1.6.5" # 安装 qr-code
  8. sudo -u www-data php /opt/www/composer.phar require "enygma/yubikey" "~3.2" # 安装 yubikey
  9. vi /opt/www/roundcubemail/config/config.inc.php # 启用 kolab_2fa 插件

参考:https://git.kolab.org/diffusion/RPK/browse/master/plugins/kolab_2fa/

roundcube 前端添加 2fa 二次验证

roundcube 常规用户名和密码登录后,会再次要求 2fa 验证

在 iredmail 1.7.1 - roundcube 1.6.8 里安装 kolab_notes 3.5.5 插件 - 未成功配置 - 提示 required kolabformat module not found

环境和已下载的文件,请看上一篇。继续安装插件 kolab_notes 。

步骤:

  1. cd /opt/www/roundcubemail/plugins/ # 转到 roundcube 插件目录
  2. cp -a /opt/www/roundcubemail-plugins-kolab/plugins/kolab_notes ./ # 复制 tasklist 插件文件
  3. vi /opt/www/roundcubemail/config/config.inc.php # 启用 tasklist 插件

结果:kolab_notes 未能成功使用,新建 笔记 时,roundcube 前端提示“保存时发生错误”,web 日志显示 roundcube: PHP Error: required kolabformat module not found (POST /mail/?_task=notes&_action=list) 。 原因很可能是 kolab_notes 的插件,当前没有为 caldav 做相应的配套适配(calendar 和 tasklist 有适配 caldav )。

新建 笔记 时,roundcube 前端提示“保存时发生错误”

web 日志显示 roundcube: <h7oub9sp> PHP Error: required kolabformat module

参考:https://git.kolab.org/diffusion/RPK/browse/master/plugins/kolab_notes/