الأدلة التقنية

ترحيل صناديق البريد بين خادمي mailcow عبر الـ API

إذا كنت ستنقل بريد مؤسستك من سيرفر mailcow إلى آخر، فسوف تجد هنا سكربتاً واحداً ينقل كل الصناديق ورسائلها عبر الـ API دون أن تعرف كلمة مرور أي مستخدم، ثم ترتيب تبديل سجل MX بحيث لا تضيع أي رسالة.

ترحيل صناديق البريد بين خادمي mailcow عبر الـ API

في هذا الدليل سوف ننقل صناديق البريد Mailboxes من سيرفر mailcow قديم إلى سيرفر Server جديد دون أن يتوقف البريد لحظة واحدة، وذلك عبر ال API الذي يوفره mailcow على السيرفرين، حيث يقرأ سكربت Script واحد قائمة الصناديق من السيرفر القديم، وينشئ لكل مستخدم كلمة مرور تطبيق App Password خاصة بالترحيل Migration فلا تحتاج إلى كلمة مروره الأصلية، ثم ينشئ الصندوق على السيرفر الجديد ويضيف له مهمة مزامنة Sync Job تنسخ الرسائل عبر imapsync. والسبب أن الطريقة اليدوية، أي إنشاء خمسين صندوقاً من اللوحة وطلب كلمة مرور كل موظف أو توحيد كلمات المرور كلها، تتحول إلى ساعات من النسخ واللصق، وهي أسوأ الطرق على الإطلاق أمنياً. وإذا لم تثبت السيرفر الجديد بعد، فابدأ بدليل تثبيت خادم البريد mailcow باستخدام Docker، وإذا كنت تخطط لنقل بريد المؤسسة كلها على مراحل فالخطة العامة في دليل استضافة البريد الإلكتروني للمؤسسات.

وقد يتساءل البعض: لماذا لا ننسخ السيرفر القديم كله كما هو؟ والإجابة أن هذا هو الأبسط عندما تنقل السيرفر كله إلى سيرفر فارغ ولديك دخول SSH إلى الاثنين، وعندها اتبع دليل الترحيل الرسمي الذي ينسخ ال Volumes كاملة بالأمر rsync، أما إذا كان السيرفر الجديد يخدم نطاقات Domains أخرى، أو كنت تنقل نطاقاً واحداً، أو لا تملك دخولاً إلى نظام السيرفر القديم، فالطريقة التي يشرحها هذا الدليل هي المناسبة.

وسوف نناقش في هذا المقال ما يلي:

  • ترتيب خطوات الترحيل، ولماذا يبدأ بتخفيض قيمة TTL قبل العمل بيوم.
  • تفعيل ال API على السيرفرين، وحصر استخدام المفتاح في عنوان جهازك.
  • سكربت PowerShell 7 ينشئ الصناديق ومهام المزامنة، وما يقابله في Bash مع curl، وكيف يتعامل كل منهما مع كلمات المرور حتى لا تظهر على الشاشة.
  • التحقق من اكتمال المزامنة، وتبديل سجل MX، والمزامنة النهائية.
  • تنظيف المفاتيح وكلمات المرور بعد الترحيل، وأشهر المشكلات وحلولها.

ما تحتاجه قبل أن تبدأ Requirements

  • سيرفران يعملان بـ mailcow-dockerized، القديم old-mail.example.com والجديد mail.example.com، ولكل منهما شهادة TLS Certificate صالحة لاسمه، والسبب أن السكربت ومهام المزامنة سوف تتحقق من الشهادتين كما سيأتي. وحدث السيرفرين إلى آخر إصدار قبل أن تبدأ، خصوصاً القديم الذي غالباً ما يكون متأخراً في التحديثات.
  • حساب مدير Administrator على السيرفرين، حتى تنشئ مفتاح ال API Key.
  • النطاق مضاف مسبقاً على السيرفر الجديد، بحصة Quota وعدد صناديق يتسعان لكل المستخدمين، لأن السكربت ينشئ الصناديق ولكنه لا ينشئ النطاقات.
  • أن يصل السيرفر الجديد إلى المنفذ 993 (IMAPS) على السيرفر القديم، لأن المزامنة تجري بين السيرفرين مباشرة ولا تمر بجهازك.
  • جهاز إدارة يعمل عليه PowerShell 7 على Windows أو Linux أو macOS، ومن يفضل Bash يحتاج إلى curl وjq وopenssl.

كيف تخطط للترحيل؟

أغلب مشكلات الترحيل لا تأتي من الأوامر وإنما من ترتيب الخطوات، فإذا حولت سجل MX قبل أن تنتهي المزامنة الأولى، فسوف يفتح المستخدمون صناديق فارغة على السيرفر الجديد، وإذا أطفأت السيرفر القديم مبكراً، فسوف تضيع الرسائل التي وصلته أثناء انتشار Propagation سجلات DNS، لذلك التزم بالترتيب التالي:

  1. اخفض قيمة TTL لسجل MX وللسجل A الخاص باسم السيرفر إلى 300 ثانية، وافعل ذلك قبل الترحيل بمدة لا تقل عن قيمة TTL الحالية، أي قبله بيوم كامل إذا كانت القيمة الحالية 86400، والسبب أن السيرفرات الأخرى تحتفظ بالقيمة القديمة في ذاكرتها المؤقتة Cache حتى تنتهي مدتها كما يشرح معيار DNS وكما شرحنا في شرح DNS وسجلاته للمبتدئين، فإذا خفضتها في يوم التبديل نفسه فلن يستفيد أحد من التخفيض.
  2. أنشئ الصناديق ومهام المزامنة بالسكربت، وابدأ بصندوق تجريبي واحد قبل البقية.
  3. انتظر انتهاء المزامنة الأولى لكل الصناديق، وقارن عدد الرسائل على السيرفرين.
  4. جهز سجلات DNS للسيرفر الجديد، أي أضف عنوانه إلى سجل SPF، وانقل مفتاح DKIM أو انشر مفتاح السيرفر الجديد، واضبط سجل PTR عند مزود السيرفر، وتفاصيل هذه السجلات في توثيق DNS في mailcow، وفكرة كل سجل منها في شرح SPF وDKIM وDMARC للمبتدئين.
  5. حول سجل MX إلى السيرفر الجديد، وأبق مهام المزامنة تعمل حتى تلتقط ما يصل إلى السيرفر القديم أثناء الانتشار.
  6. نفذ المزامنة النهائية ثم أوقف المهام، وأبق السيرفر القديم متاحاً للقراءة أسبوعاً على الأقل قبل حذفه.
💡
هذا الدليل ينقل الصناديق والرسائل فقط، أما الأسماء المستعارة Aliases وقواعد التصفية Sieve Filters والتقويم Calendars وجهات الاتصال Contacts في SOGo وإعدادات الرسائل المزعجة لكل مستخدم فلا تنقلها مهمة المزامنة، لذلك انقلها من الواجهة أو عبر ال API في خطوة مستقلة، وأخبر المستخدمين بما يجب أن يصدروه بأنفسهم قبل التبديل.

تفعيل ال API على السيرفرين

ال API في mailcow معطل حتى تفعله، ولكل سيرفر نوعان من المفاتيح، مفتاح للقراءة فقط Read-Only يعمل مع طلبات القراءة وحدها، ومفتاح للقراءة والكتابة Read-Write يعمل مع كل الطلبات، والسكربت يحتاج إلى مفتاح القراءة والكتابة على السيرفرين، لأنه ينشئ كلمات مرور التطبيقات على السيرفر القديم، والصناديق ومهام المزامنة على السيرفر الجديد. والخطوات على كل سيرفر كما يلي:

  1. سجل الدخول بحساب المدير، وافتح System ثم Configuration ثم Access، ثم Edit administrator details، ووسع قسم API، كما يذكر ملف مواصفات ال API في مستودع mailcow.
  2. في قسم Read-Write Access اكتب في الحقل Allow API access from these IPs/CIDR network notations عنوان IP العام لجهاز الإدارة فقط، ثم فعل المفتاح واحفظ.
  3. لا تفعل الخيار Skip IP check for API، والسبب أن مفتاح القراءة والكتابة يعطي صلاحيات Permissions المدير كاملة، وقائمة العناوين المسموح بها هي ما يمنع استخدامه إذا تسرب.

ولاحظ أن كل سيرفر mailcow يعرض توثيق ال API الخاص بإصداره على المسار /api، مثل https://mail.example.com/api/، فارجع إليه إذا كان إصدار سيرفرك غير الإصدار الذي يتناوله هذا الدليل، لأنه المرجع الأدق لسيرفرك.

سكربت الترحيل

ماذا يفعل السكربت؟

المدير لا يعرف كلمات مرور المستخدمين ولا يجب أن يعرفها، لذلك ينشئ السكربت لكل مستخدم كلمة مرور تطبيق على السيرفر القديم تعمل مع IMAP وحده، وتسجل مهمة المزامنة دخولها بهذه الكلمة، فتبقى كلمة المرور الأصلية للمستخدم كما هي. ولكل صندوق يجري العمل بالترتيب التالي:

  1. يقرأ قائمة الصناديق من GET /api/v1/get/mailbox/all على السيرفر القديم.
  2. يتخطى الصندوق إذا كانت له مهمة مزامنة من السيرفر القديم، وبالتالي تستطيع إعادة تشغيل السكربت بأمان بعد أي خطأ.
  3. ينشئ الصندوق على السيرفر الجديد عبر POST /api/v1/add/mailbox بكلمة مرور مؤقتة Temporary Password عشوائية خاصة به، ويلزم المستخدم بتغييرها عند أول دخول.
  4. ينشئ كلمة مرور تطبيق عشوائية على السيرفر القديم عبر POST /api/v1/add/app-passwd.
  5. يضيف مهمة المزامنة على السيرفر الجديد عبر POST /api/v1/add/syncjob.

السكربت

احفظ السكربت التالي باسم migrate-mailcow.ps1 في مجلد لا يصل إليه غيرك، ولاحظ أنه لا يحتوي على أي مفتاح، وإنما يقرأ المفتاحين من متغيرات البيئة Environment Variables، فلا يصلان بالخطأ إلى مستودع أو رسالة بريد:

#Requires -Version 7.0
param(
    [string]$OldServer = "old-mail.example.com",
    [string]$NewServer = "mail.example.com",
    [string[]]$Only,
    [string]$CsvPath = "./migration-credentials.csv",
    [switch]$Step,
    [switch]$SkipOldCertCheck
)

$ErrorActionPreference = "Stop"
if (-not $env:MAILCOW_OLD_API_KEY -or -not $env:MAILCOW_NEW_API_KEY) {
    throw "Set MAILCOW_OLD_API_KEY and MAILCOW_NEW_API_KEY first."
}
$OldHeaders = @{ "X-API-Key" = $env:MAILCOW_OLD_API_KEY }
$NewHeaders = @{ "X-API-Key" = $env:MAILCOW_NEW_API_KEY }
$OldTls = @{}
if ($SkipOldCertCheck) { $OldTls.SkipCertificateCheck = $true }
$AppName = "migration-" + (Get-Date -Format "yyyyMMdd")
$ErrorLog = "./migration-errors.log"

function New-RandomPassword([int]$Length = 24) {
    $sets = "ABCDEFGHJKLMNPQRSTUVWXYZ", "abcdefghijkmnpqrstuvwxyz", "23456789", "!#%+-=@_"
    $all = -join $sets
    $rng = [System.Security.Cryptography.RandomNumberGenerator]
    $chars = @(foreach ($s in $sets) { $s[$rng::GetInt32($s.Length)] })
    $chars += @(1..($Length - $sets.Count) | ForEach-Object { $all[$rng::GetInt32($all.Length)] })
    -join ($chars | Sort-Object { $rng::GetInt32([int]::MaxValue) })
}

function Invoke-Mailcow([string]$Base, [hashtable]$Headers, [string]$Path, $Body, [hashtable]$Extra = @{}) {
    $params = @{ Uri = "https://$Base$Path"; Headers = $Headers } + $Extra
    if ($null -ne $Body) {
        $params.Method = "Post"
        $params.ContentType = "application/json"
        $params.Body = ConvertTo-Json -InputObject $Body -Depth 5 -Compress
    }
    $response = Invoke-RestMethod @params
    $failed = @($response) | Where-Object { $_.type -and $_.type -ne "success" }
    if ($failed) { throw (@($failed | ForEach-Object { @($_.msg) -join " " }) -join "; ") }
    $response
}

$accounts = Invoke-Mailcow $OldServer $OldHeaders "/api/v1/get/mailbox/all" $null $OldTls
if ($Only) { $accounts = $accounts | Where-Object { $Only -contains $_.username } }
$existingBoxes = @(Invoke-Mailcow $NewServer $NewHeaders "/api/v1/get/mailbox/all" $null).username
$existingJobs = @(Invoke-Mailcow $NewServer $NewHeaders "/api/v1/get/syncjobs/all/no_log" $null) |
    Where-Object { $_.host1 -eq $OldServer } | ForEach-Object { $_.user2 }

foreach ($account in $accounts) {
    $user = $account.username
    Write-Host "Processing $user"
    try {
        if ($existingJobs -contains $user) { Write-Host "  sync job exists, skipped"; continue }

        if ($existingBoxes -notcontains $user) {
            $mailboxPassword = New-RandomPassword
            Invoke-Mailcow $NewServer $NewHeaders "/api/v1/add/mailbox" @{
                active          = "1"
                domain          = $account.domain
                local_part      = $account.local_part
                name            = $account.name
                password        = $mailboxPassword
                password2       = $mailboxPassword
                quota           = [string][math]::Floor($account.quota / 1MB)
                force_pw_update = "1"
                tls_enforce_in  = [string]$account.attributes.tls_enforce_in
                tls_enforce_out = [string]$account.attributes.tls_enforce_out
            } | Out-Null
            [pscustomobject]@{ username = $user; temporary_password = $mailboxPassword } |
                Export-Csv -Path $CsvPath -Append -NoTypeInformation
            Write-Host "  mailbox created"
        }

        $appPassword = New-RandomPassword
        Invoke-Mailcow $OldServer $OldHeaders "/api/v1/add/app-passwd" @{
            active      = "1"
            username    = $user
            app_name    = $AppName
            app_passwd  = $appPassword
            app_passwd2 = $appPassword
            protocols   = @("imap_access")
        } $OldTls | Out-Null

        Invoke-Mailcow $NewServer $NewHeaders "/api/v1/add/syncjob" @{
            username            = $user
            host1               = $OldServer
            port1               = "993"
            enc1                = "SSL"
            user1               = $user
            password1           = $appPassword
            mins_interval       = "20"
            subfolder2          = ""
            maxage              = "0"
            maxbytespersecond   = "0"
            timeout1            = "600"
            timeout2            = "600"
            exclude             = "(?i)spam|(?i)junk"
            custom_params       = $(if ($SkipOldCertCheck) { "" } else { "--sslargs1=SSL_verify_mode=1" })
            delete2duplicates   = "1"
            delete1             = "0"
            delete2             = "0"
            automap             = "1"
            skipcrossduplicates = "0"
            subscribeall        = "1"
            active              = "1"
        } | Out-Null
        Write-Host "  sync job created"
    }
    catch {
        Write-Warning "  failed: $_"
        Add-Content -Path $ErrorLog -Value "$(Get-Date -Format s) $user $_"
    }
    if ($Step) { Read-Host "Press Enter for the next mailbox" | Out-Null }
}

في الإعداد أعلاه لاحظ التالي:

  • كلمة مرور مختلفة لكل صندوق: الدالة New-RandomPassword تولد كلمة من 24 حرفاً بمولد الأرقام العشوائية الآمن Cryptographically Secure RNG RandomNumberGenerator.GetInt32، وتضمن فيها حرفاً كبيراً وحرفاً صغيراً ورقماً ورمزاً حتى تجتاز سياسة تعقيد كلمات المرور في mailcow، ولا تستخدم كلمة واحدة لكل الصناديق، والسبب أن من يعرفها من مستخدم واحد يدخل صناديق الجميع حتى يغيروها.
  • كلمات المرور لا تظهر على الشاشة: يكتب السكربت الكلمات المؤقتة في الملف migration-credentials.csv فقط، أما كلمات مرور التطبيقات فلا يكتبها في أي مكان لأنها محفوظة في مهمة المزامنة.
  • رسالة الخطأ وحدها: يعيد mailcow الرمز HTTP 200 حتى عندما يرفض الطلب، ويضع النتيجة في الحقل type، لذلك تعامل الدالة Invoke-Mailcow أي نتيجة غير success على أنها خطأ، وتأخذ من الرد الحقل msg وحده، والسبب أن mailcow يعيد مع الخطأ الحقل log وفيه الطلب الذي أرسلته، وفي مهمة المزامنة تحديداً تعود فيه كلمة مرور التطبيق password1 كما هي دون إخفاء، فلو طبعنا الرد كاملاً لظهرت الكلمة على الشاشة وفي ملف الأخطاء. وبعد ذلك يسجل السكربت الصندوق في migration-errors.log وينتقل إلى التالي.
  • التحقق من شهادة TLS: يتصل السكربت بالسيرفرين عبر HTTPS ويتحقق من شهادتيهما، وهذا هو السلوك الافتراضي في Invoke-RestMethod، ويطلب كذلك من imapsync أن يتحقق من شهادة السيرفر القديم كما سيأتي.
  • الحصة وإعدادات TLS تنتقل كما هي: السيرفر القديم يعيد الحصة بالبايت، والجديد يتوقعها بالميغابايت MiB، لذلك يحولها السكربت، والقيمة 0 تعني حصة غير محدودة.

حقول مهمة المزامنة

تحول mailcow هذه الحقول إلى خيارات لأمر imapsync كما ترى في ملف تشغيل المهام، وكل خيار مشروح بالتفصيل في توثيق imapsync، والجدول التالي يبين قيمة كل حقل ومعناه:

الحقلالقيمةالمعنى
usernameعنوان المستخدمالصندوق الهدف على السيرفر الجديد.
host1، port1old-mail.example.com، 993السيرفر المصدر ومنفذ IMAPS.
enc1SSLاتصال مشفر من البداية على المنفذ 993، والقيمة TLS تعني STARTTLS على المنفذ 143، أما PLAIN فترسل كلمة المرور دون تشفير Encryption فلا تستخدمها.
user1، password1العنوان وكلمة مرور التطبيقبيانات الدخول إلى السيرفر القديم.
mins_interval20تعيد المهمة المزامنة كل 20 دقيقة، فتلتقط الرسائل الجديدة حتى التبديل.
subfolder2فارغتنسخ المجلدات إلى المستوى الأول في الصندوق وليس إلى مجلد فرعي.
maxage، maxbytespersecond0دون حد لعمر الرسائل أو لسرعة النقل.
timeout1، timeout2600مهلة الاتصال بالثواني للسيرفر القديم والجديد، والقيمة الافتراضية في imapsync هي 120 ثانية، وقد لا تكفي مع الصناديق الكبيرة.
exclude(?i)spam|(?i)junkيستثني مجلدات الرسائل المزعجة Spam أياً كانت حالة أحرفها.
custom_params--sslargs1=SSL_verify_mode=1يطلب من imapsync التحقق من شهادة السيرفر القديم، لأنه لا يتحقق منها افتراضياً، ولاحظ أن mailcow لا يقبل في هذا الحقل إلا الخيارات الموجودة في قائمته المسموح بها Whitelist، و--sslargs1 منها.
delete2duplicates1يحذف الرسائل المكررة في الصندوق الهدف.
delete1، delete20لا يحذف شيئاً من السيرفر القديم، ولا يحذف من الجديد ما ليس في القديم، وبالتالي إذا أخطأت في شيء تبقى النسخة الأصلية سليمة.
automap1يطابق المجلدات المتشابهة، مثل Sent Items وSent.
skipcrossduplicates0ينسخ الرسالة الموجودة في أكثر من مجلد إلى كل مجلد كما هي.
subscribeall1يشترك في جميع المجلدات، حتى تظهر في برامج البريد دون إعداد.
active1تبدأ المهمة في الدورة التالية.
⚠️
تحفظ mailcow كلمة المرور في مهمة المزامنة نصاً صريحاً كما تنبه واجهتها، لذلك يقصر السكربت كلمة مرور التطبيق على IMAP وحده، وعليك أن تحذف المهام وكلمات مرور التطبيقات فور انتهاء الترحيل كما في قسم التنظيف أدناه.

التشغيل: صندوق تجريبي أولاً

أدخل المفتاحين في متغيرات البيئة بالأمر Read-Host -MaskInput، وعند كل سؤال الصق المفتاح الذي نسخته من السيرفر المعني، أي ما نرمز له في هذا الدليل بـ YOUR_OLD_API_KEY وYOUR_NEW_API_KEY، ولا تكتبهما مباشرة في سطر الأوامر، والسبب أن PowerShell يحفظ ما تكتبه في ملف سجل الأوامر:

$env:MAILCOW_OLD_API_KEY = Read-Host "Old server API key" -MaskInput
$env:MAILCOW_NEW_API_KEY = Read-Host "New server API key" -MaskInput

ثم شغل السكربت على صندوق واحد مع التوقف بعد كل صندوق:

./migrate-mailcow.ps1 -Only [email protected] -Step

والمخرج سوف يكون كما يلي:

Processing [email protected]
  mailbox created
  sync job created
Press Enter for the next mailbox:

تحقق من هذا الصندوق كما في قسم التحقق أدناه، وإذا اكتملت مزامنته فشغل السكربت على باقي الصناديق دون -Only، وسوف يتخطى الصندوق التجريبي لأن مهمته موجودة:

./migrate-mailcow.ps1
🛑
الملف migration-credentials.csv يحتوي على كلمات المرور المؤقتة لجميع المستخدمين، فلا ترسله بالبريد ولا تضعه في مجلد مشترك، وإنما سلم كل مستخدم كلمته عبر قناة آمنة ثم احذف الملف، وحتى ذلك الحين أبقه في مجلد لا يقرؤه غيرك، وفي Linux اضبط صلاحياته بالأمر chmod 600 migration-credentials.csv.

والحقل force_pw_update يلزم المستخدم بتعيين كلمة مرور جديدة قبل أن يستخدم خدمات العمل الجماعي Groupware في SOGo، لذلك اطلب من كل مستخدم أن يدخل أولاً إلى البريد عبر الويب Webmail ويغير كلمته، ثم يضبط برامج البريد على هاتفه وحاسوبه.

ماذا لو لم تكن للسيرفر القديم شهادة صالحة؟

قبل أي شيء تحقق من شهادة السيرفر القديم من السيرفر الجديد نفسه، لأنه هو الذي سوف يتصل به:

openssl s_client -connect old-mail.example.com:993 -verify_hostname old-mail.example.com -verify_return_error -brief < /dev/null

إذا ظهر السطر Verification: OK فالشهادة صالحة والسكربت يعمل كما هو، وإذا فشل التحقق فالحل الصحيح أن تصدر للسيرفر القديم شهادة Let's Encrypt من mailcow نفسه قبل الترحيل. والسبب أنك إذا عطلت التحقق، فأي طرف في منتصف الطريق يستطيع أن ينتحل السيرفر القديم، وهذا ما يسمى هجوم الوسيط Man-in-the-Middle، وعندها يحصل على مفتاح ال API وعلى كلمات مرور التطبيقات، أي على بريد المؤسسة كله.

وإذا لم تستطع إصدار شهادة، فاستخدم المفتاح -SkipOldCertCheck الذي يعطل التحقق في اتصالات السيرفر القديم وحده، ويترك الحقل custom_params فارغاً، وتبقى اتصالات السيرفر الجديد محققة، ولكن لا تستخدمه إلا عبر مسار شبكة تثق به، مثل شبكة خاصة افتراضية VPN أو شبكة داخلية بين السيرفرين، ثم ألغ المفتاحين بعد الترحيل:

./migrate-mailcow.ps1 -SkipOldCertCheck

البديل لمديري Linux: Bash مع curl

هذا السكربت يفعل ما يفعله سكربت PowerShell بالحقول نفسها، ولكنه لا يفحص المهام الموجودة، فإذا فشل إنشاء الصندوق لأنه موجود من قبل فإنه يتخطاه، لذلك شغله مرة واحدة على سيرفرين لم تبدأ عليهما الترحيل، وأكمل أي صندوق يظهر أمامه failed بسكربت PowerShell مع -Only أو من الواجهة. ولاحظ فيه أن السطر umask 077 يضمن ألا يقرأ ملف كلمات المرور غيرك، وأن الدالة newpw تولد عشرين حرفاً عشوائياً من openssl rand ثم تضيف إليها حرفاً كبيراً وصغيراً ورقماً ورمزاً لتجتاز سياسة التعقيد، وأن المفاتيح وكلمات المرور لا تمر في سطر أوامر أي برنامج، فالدالة key تمرر الترويسة Header إلى curl من ملف مؤقت بالصيغة -H @<(…)، والطلب يصل إليه من المدخل القياسي stdin، وكلمات المرور تصل إلى jq عبر متغير البيئة PW، والسبب أن سطر أوامر أي برنامج يعمل يظهر لكل مستخدم على الجهاز في قائمة العمليات Processes بالأمر ps:

#!/usr/bin/env bash
set -euo pipefail
OLD=old-mail.example.com
NEW=mail.example.com
: "${MAILCOW_OLD_API_KEY:?}" "${MAILCOW_NEW_API_KEY:?}"
umask 077
CSV=migration-credentials.csv
APP_NAME="migration-$(date +%Y%m%d)"

key()  { printf 'X-API-Key: %s\n' "$1"; }
get()  { curl -fsS -H @<(key "$2") "https://$1$3"; }
post() { curl -fsS -H @<(key "$2") -H "Content-Type: application/json" --data-binary @- "https://$1$3" <<<"$4" |
         jq -e 'if type == "array" then all(.[]; .type == "success") else .type == "success" end' >/dev/null; }
newpw() { printf '%sAa1#' "$(openssl rand -base64 32 | tr -dc 'A-Za-z0-9' | cut -c1-20)"; }

get "$OLD" "$MAILCOW_OLD_API_KEY" /api/v1/get/mailbox/all | jq -c '.[]' | while read -r box; do
  user=$(jq -r .username <<<"$box")
  echo "Processing $user"

  pw=$(newpw)
  body=$(PW=$pw jq -cn --argjson b "$box" '{active:"1", domain:$b.domain,
    local_part:$b.local_part, name:$b.name, password:env.PW, password2:env.PW, force_pw_update:"1",
    quota:(($b.quota / 1048576) | floor | tostring),
    tls_enforce_in:(($b.attributes.tls_enforce_in // 0) | tostring),
    tls_enforce_out:(($b.attributes.tls_enforce_out // 0) | tostring)}')
  if ! post "$NEW" "$MAILCOW_NEW_API_KEY" /api/v1/add/mailbox "$body"; then
    echo "  mailbox failed, skipped" >&2; continue
  fi
  printf '%s,%s\n' "$user" "$pw" >> "$CSV"

  app=$(newpw)
  body=$(PW=$app jq -cn --arg u "$user" --arg n "$APP_NAME" '{active:"1", username:$u,
    app_name:$n, app_passwd:env.PW, app_passwd2:env.PW, protocols:["imap_access"]}')
  if ! post "$OLD" "$MAILCOW_OLD_API_KEY" /api/v1/add/app-passwd "$body"; then
    echo "  app password failed, skipped" >&2; continue
  fi

  body=$(PW=$app jq -cn --arg u "$user" --arg h "$OLD" '{username:$u, host1:$h,
    port1:"993", enc1:"SSL", user1:$u, password1:env.PW, mins_interval:"20", subfolder2:"",
    maxage:"0", maxbytespersecond:"0", timeout1:"600", timeout2:"600",
    exclude:"(?i)spam|(?i)junk", custom_params:"--sslargs1=SSL_verify_mode=1",
    delete2duplicates:"1", delete1:"0", delete2:"0", automap:"1", skipcrossduplicates:"0",
    subscribeall:"1", active:"1"}')
  if ! post "$NEW" "$MAILCOW_NEW_API_KEY" /api/v1/add/syncjob "$body"; then
    echo "  sync job failed, skipped" >&2; continue
  fi
  echo "  sync job created"
done

أدخل المفتاحين دون أن يظهرا على الشاشة أو في سجل الأوامر، ثم شغل السكربت:

read -rsp "Old server API key: " MAILCOW_OLD_API_KEY; echo
read -rsp "New server API key: " MAILCOW_NEW_API_KEY; echo
export MAILCOW_OLD_API_KEY MAILCOW_NEW_API_KEY
bash migrate-mailcow.sh

التحقق من النجاح Verification

افتح على السيرفر الجديد E-Mail ثم Configuration ثم Synchronizations كما في توثيق مهام المزامنة، وسوف تجد مهمة لكل صندوق، حيث يعرض العمود Last run result نتيجة آخر تشغيل، والقيمة Waiting في العمود Status تعني أن المهمة تنتظر دورتها التالية، والصورة التالية تبين ذلك:

قائمة مهام المزامنة في mailcow ولكل مهمة نتيجة Success وحالة Waiting
مهام المزامنة على السيرفر الجديد بعد أول تشغيل (حجبنا أسماء الصناديق والسيرفر)
  • الرابط Open logs يفتح سجلات Logs الخاصة بـ imapsync في هذه المهمة، وفي آخر السجل ملخص بعدد الرسائل المنسوخة والمتخطاة والأخطاء.
  • ادخل إلى البريد عبر الويب على السيرفر الجديد بالصندوق التجريبي، وافتح رسائل قديمة من مجلدات مختلفة، وتأكد أن المرفقات سليمة.

ولتقارن المجلدات واحداً واحداً، نفذ هذا الأمر في المجلد /opt/mailcow-dockerized على كل سيرفر، وهو أمر doveadm الذي يعرض عدد الرسائل في كل مجلد:

docker compose exec dovecot-mailcow doveadm -f table mailbox status -u [email protected] messages '*'

قارن عدد الرسائل في كل صندوق على السيرفرين من الحقل messages الذي يعيده ال API:

$old = Invoke-RestMethod -Uri "https://old-mail.example.com/api/v1/get/mailbox/all" -Headers @{ "X-API-Key" = $env:MAILCOW_OLD_API_KEY }
$new = Invoke-RestMethod -Uri "https://mail.example.com/api/v1/get/mailbox/all" -Headers @{ "X-API-Key" = $env:MAILCOW_NEW_API_KEY }
$newCount = @{}
foreach ($m in $new) { $newCount[$m.username] = $m.messages }
$old | ForEach-Object { [pscustomobject]@{ mailbox = $_.username; old = $_.messages; new = $newCount[$_.username] } } | Format-Table

وعادة يكون العدد على السيرفر الجديد أقل قليلاً، لأن المهمة تستثني مجلدات الرسائل المزعجة وتحذف المكرر.

تبديل سجل MX والمزامنة النهائية

عندما تكتمل المزامنة الأولى لكل الصناديق اختر وقتاً هادئاً لتبديل سجل MX، وقبل التبديل بساعة اجعل الفاصل الزمني لكل المهام 5 دقائق عبر POST /api/v1/edit/syncjob، حتى تلتقط الرسائل التي تصل إلى السيرفر القديم بسرعة أكبر:

$headers = @{ "X-API-Key" = $env:MAILCOW_NEW_API_KEY }
$jobs = Invoke-RestMethod -Uri "https://mail.example.com/api/v1/get/syncjobs/all/no_log" -Headers $headers
$ids = @($jobs | Where-Object { $_.host1 -eq "old-mail.example.com" } | ForEach-Object { [string]$_.id })
$body = @{ items = $ids; attr = @{ mins_interval = "5" } } | ConvertTo-Json -Compress
Invoke-RestMethod -Uri "https://mail.example.com/api/v1/edit/syncjob" -Headers $headers -Method Post -ContentType "application/json" -Body $body
  1. غير سجل MX للنطاق ليشير إلى mail.example.com، وحدث سجلات SPF وDKIM إذا لم تفعل ذلك من قبل.
  2. راقب وصول الرسائل الجديدة إلى السيرفر الجديد، وأرسل رسالة اختبار من حساب خارجي.
  3. أبق المهام تعمل يوماً أو يومين على الأقل، والسبب أن بعض السيرفرات المرسلة تحتفظ بسجل MX القديم في ذاكرتها وتعيد المحاولة على السيرفر القديم.
  4. عندما تتوقف الرسائل عن الوصول إلى السيرفر القديم، وترى ذلك في سجل Postfix عليه، انتظر حتى تنجح دورة مزامنة أخيرة لكل مهمة، وهذه هي المزامنة النهائية، وبعدها أوقف المهام أو احذفها.

ولاحظ أن المزامنة في اتجاه واحد فقط، فكل دورة تقارن الصندوقين وتنسخ إلى السيرفر الجديد أي رسالة موجودة في القديم وغير موجودة في الجديد، وبالتالي إذا حذف مستخدم رسالة قديمة على السيرفر الجديد أو نقلها إلى مجلد آخر قبل المزامنة النهائية، فسوف تعود إلى مكانها في الدورة التالية، لذلك اطلب من المستخدمين ألا يرتبوا صناديقهم حتى تحذف المهام، ولا تطل فترة التبديل أكثر من الحاجة.

⚠️
لا تحذف السيرفر القديم فور التبديل، وإنما أبقه يعمل أسبوعاً على الأقل، وخذ منه نسخة احتياطية Backup كاملة قبل إطفائه، فإذا اكتشف مستخدم بعد أيام أن مجلداً ناقصاً فسوف تجده هناك.

التنظيف بعد الترحيل

بعد المزامنة النهائية تبقى على السيرفرين مفاتيح وكلمات مرور لم يعد لها عمل، وكل منها باب مفتوح إلى بريد المؤسسة، لذلك أغلقها كلها بالخطوات التالية:

  1. تحقق من الواجهة أن قائمة Synchronizations على السيرفر الجديد خالية من مهام السيرفر القديم، وأنه لا توجد كلمة مرور تطبيق باسم migration- في أي صندوق على السيرفر القديم.
  2. عطل مفتاح القراءة والكتابة على السيرفرين، أو أعد توليده بالزر Regenerate API key إذا كنت ستحتاج إلى ال API لاحقاً، وامسح قائمة العناوين المسموح بها.
  3. احذف migration-credentials.csv وmigration-errors.log من جهاز الإدارة، وأغلق جلسة PowerShell حتى تزول متغيرات البيئة.

احذف مهام المزامنة من السيرفر الجديد، وكلمات مرور التطبيقات التي تبدأ بـ migration- من السيرفر القديم، والسكربت التالي يحذفها عبر /api/v1/delete/syncjob و/api/v1/delete/app-passwd، فاحفظه باسم cleanup-mailcow.ps1 وشغله بالمفتاحين نفسيهما:

#Requires -Version 7.0
param(
    [string]$OldServer = "old-mail.example.com",
    [string]$NewServer = "mail.example.com",
    [switch]$SkipOldCertCheck
)

$ErrorActionPreference = "Stop"
$OldHeaders = @{ "X-API-Key" = $env:MAILCOW_OLD_API_KEY }
$NewHeaders = @{ "X-API-Key" = $env:MAILCOW_NEW_API_KEY }
$OldTls = @{}
if ($SkipOldCertCheck) { $OldTls.SkipCertificateCheck = $true }

$jobs = Invoke-RestMethod -Uri "https://$NewServer/api/v1/get/syncjobs/all/no_log" -Headers $NewHeaders
$jobIds = @($jobs | Where-Object { $_.host1 -eq $OldServer } | ForEach-Object { [string]$_.id })
if ($jobIds) {
    Invoke-RestMethod -Uri "https://$NewServer/api/v1/delete/syncjob" -Headers $NewHeaders -Method Post `
        -ContentType "application/json" -Body (ConvertTo-Json -InputObject $jobIds -Compress)
}

$accounts = Invoke-RestMethod -Uri "https://$OldServer/api/v1/get/mailbox/all" -Headers $OldHeaders @OldTls
foreach ($account in $accounts) {
    $mailbox = [uri]::EscapeDataString($account.username)
    $apps = Invoke-RestMethod -Uri "https://$OldServer/api/v1/get/app-passwd/all/$mailbox" -Headers $OldHeaders @OldTls
    $appIds = @($apps | Where-Object { $_.name -like "migration-*" } | ForEach-Object { [string]$_.id })
    if ($appIds) {
        Invoke-RestMethod -Uri "https://$OldServer/api/v1/delete/app-passwd" -Headers $OldHeaders @OldTls -Method Post `
            -ContentType "application/json" -Body (ConvertTo-Json -InputObject $appIds -Compress)
    }
}
📌
في 3 مارس 2023 نشر فريق mailcow تنبيهاً أمنياً عن الثغرة CVE-2023-26490 في مهام المزامنة، حيث كان imapsync يبني أمراً في ال Shell لتشغيل openssl عند استخدام طريقة المصادقة XOAUTH2، ويضع فيه أجزاء من كلمة المرور دون أي تحقق، وبالتالي فأي مستخدم يملك صلاحية إنشاء مهام المزامنة كان يستطيع أن يكتب أوامر داخل حقل كلمة المرور ويحصل على Shell داخل ال Container الذي يشغل Dovecot. وأغلق تحديث 2023-03 الثغرة، وكان الحل المؤقت سحب صلاحية Syncjob من المستخدمين. والدرس هنا أن مهام المزامنة من أقوى ميزات mailcow وأخطرها في الوقت نفسه، لذلك أنشئها من حساب المدير فقط، ولا تعط المستخدمين صلاحية إنشائها، وحدث السيرفرين قبل أن تبدأ الترحيل، واحذف المهام فور انتهائه.

مشكلات شائعة وحلولها Troubleshooting

ال API يرفض الطلب

يعيد السيرفر هنا الرمز HTTP 401 أو 403، ومعه رسالة تبين السبب:

  • api access denied for ip …: عنوان جهاز الإدارة ليس في قائمة العناوين المسموح بها، والمطلوب هو العنوان الذي يراه السيرفر، فإذا كان جهازك خلف NAT فأضف العنوان العام للشبكة.
  • authentication failed: المفتاح خاطئ أو غير مفعل.
  • API read/write access denied: تستخدم مفتاح القراءة فقط.

ولا تكرر المحاولة الفاشلة مرات كثيرة، والسبب أن mailcow يسجل كل محاولة فاشلة عند خدمة الحظر Netfilter، وقد يحظر عنوان جهازك مؤقتاً، وإذا حدث ذلك فارفع الحظر من قسم Fail2ban parameters في إعدادات السيرفر المعني كما يشرح توثيق Netfilter.

فشل إنشاء صندوق على السيرفر الجديد

افتح migration-errors.log واقرأ رسالة الخطأ بعد اسم الصندوق، فالقيمة domain_not_found تعني أن النطاق غير مضاف على السيرفر الجديد، والرسائل التي تذكر quota تعني أن حصة النطاق لا تتسع للصندوق، وmax_mailbox_exceeded تعني أن عدد الصناديق المسموح به في النطاق وصل إلى حده، وفي الحالتين ارفع الحد من إعدادات النطاق ثم أعد تشغيل السكربت، أما object_exists فتعني أن الصندوق موجود من قبل.

المهمة تفشل بخطأ في الاتصال المشفر

إذا ظهرت النتيجة Problem with encrypted connection في العمود Last run result، فقد رفض imapsync شهادة السيرفر القديم، فنفذ أمر openssl s_client من قسم الشهادة على السيرفر الجديد وأصلح الشهادة، ولا تحذف custom_params إلا بالشروط المذكورة هناك.

المهمة تفشل في المصادقة

النتيجة Wrong username or password تعني أن السيرفر القديم رفض كلمة مرور التطبيق، والسبب غالباً أن IMAP معطل لهذا الصندوق على السيرفر القديم، أو أن كلمة التطبيق حذفت، فافحص الصندوق في الواجهة، ثم احذف المهمة وأعد تشغيل السكربت على الصندوق بالمفتاح -Only.

المهمة لا تصل إلى السيرفر القديم

النتيجة Can't connect to remote server تعني أن المنفذ 993 مغلق أمام السيرفر الجديد، فتحقق من جدار الحماية Firewall على السيرفر القديم وعند مزوده، ونفذ من السيرفر الجديد الأمر nc -vz old-mail.example.com 993.

المزامنة الأولى بطيئة جداً

الصناديق الكبيرة تأخذ ساعات في أول مزامنة، والدورات التالية أسرع لأنها تنقل الجديد فقط، ولاحظ أن mailcow تشغل المهام واحدة بعد الأخرى وليس معاً، لذلك إذا كانت أحجام الصناديق بعشرات الغيغابايت فابدأ الترحيل قبل موعد التبديل بأيام.

الخلاصة

وصلنا لنهاية الموضوع، وأهم ما فيه:

  • إنشاء الصناديق ومهام المزامنة يدوياً لا يصلح لعشرات المستخدمين، والسكربت مع كلمات مرور التطبيقات ينقل الجميع دون أن تعرف كلمة مرور أحد.
  • اخفض TTL قبل التبديل بيوم، ولا تحول سجل MX قبل أن تنتهي المزامنة الأولى، وأبق المهام تعمل يوماً أو يومين بعده.
  • اترك التحقق من الشهادات مفعلاً في السكربت وفي imapsync، ولا تطبع رد mailcow كاملاً عند الخطأ لأنه قد يحتوي على كلمة المرور.
  • بعد المزامنة النهائية احذف المهام وكلمات مرور التطبيقات وملف كلمات المرور، وعطل مفتاح ال API، وأبق السيرفر القديم أسبوعاً مع نسخة احتياطية كاملة.

سجل التحديثات Changelog

  • أكتوبر 2026: كتابة الدليل.
نشرة عرب رووت | ArabRoot

معرفة تستحق مكاناً في بريدك.

مقالات مختارة وأدوات مفيدة وأفكار لمشروعك القادم، في رسالة واحدة كل أسبوع.

يمكنك إلغاء الاشتراك متى شئت. الخصوصية

تم استلام طلبك. افتح بريدك واضغط رابط التأكيد لإتمام الاشتراك.