1
0
mirror of https://github.com/mailcow/mailcow-dockerized.git synced 2026-05-11 02:02:25 +00:00

[DockerApi] Rename DockerApi to Controller and add mailcow-adm tool

This commit is contained in:
FreddleSpl0it
2025-07-29 12:33:43 +02:00
parent d5b30a7a08
commit 0ac0e5c252
53 changed files with 3449 additions and 79 deletions

View File

@@ -0,0 +1,128 @@
import docker
from docker.errors import APIError
class Docker:
def __init__(self):
self.client = docker.from_env()
def exec_command(self, container_name, cmd, user=None):
"""
Execute a command in a container by its container name.
:param container_name: The name of the container.
:param cmd: The command to execute as a list (e.g., ["ls", "-la"]).
:param user: The user to execute the command as (optional).
:return: A standardized response with status, output, and exit_code.
"""
filters = {"name": container_name}
try:
for container in self.client.containers.list(filters=filters):
exec_result = container.exec_run(cmd, user=user)
return {
"status": "success",
"exit_code": exec_result.exit_code,
"output": exec_result.output.decode("utf-8")
}
except APIError as e:
return {
"status": "error",
"exit_code": "APIError",
"output": str(e)
}
except Exception as e:
return {
"status": "error",
"exit_code": "Exception",
"output": str(e)
}
def start_container(self, container_name):
"""
Start a container by its container name.
:param container_name: The name of the container.
:return: A standardized response with status, output, and exit_code.
"""
filters = {"name": container_name}
try:
for container in self.client.containers.list(filters=filters):
container.start()
return {
"status": "success",
"exit_code": "0",
"output": f"Container '{container_name}' started successfully."
}
except APIError as e:
return {
"status": "error",
"exit_code": "APIError",
"output": str(e)
}
except Exception as e:
return {
"status": "error",
"error_type": "Exception",
"output": str(e)
}
def stop_container(self, container_name):
"""
Stop a container by its container name.
:param container_name: The name of the container.
:return: A standardized response with status, output, and exit_code.
"""
filters = {"name": container_name}
try:
for container in self.client.containers.list(filters=filters):
container.stop()
return {
"status": "success",
"exit_code": "0",
"output": f"Container '{container_name}' stopped successfully."
}
except APIError as e:
return {
"status": "error",
"exit_code": "APIError",
"output": str(e)
}
except Exception as e:
return {
"status": "error",
"exit_code": "Exception",
"output": str(e)
}
def restart_container(self, container_name):
"""
Restart a container by its container name.
:param container_name: The name of the container.
:return: A standardized response with status, output, and exit_code.
"""
filters = {"name": container_name}
try:
for container in self.client.containers.list(filters=filters):
container.restart()
return {
"status": "success",
"exit_code": "0",
"output": f"Container '{container_name}' restarted successfully."
}
except APIError as e:
return {
"status": "error",
"exit_code": "APIError",
"output": str(e)
}
except Exception as e:
return {
"status": "error",
"exit_code": "Exception",
"output": str(e)
}

View File

@@ -0,0 +1,206 @@
import os
from modules.Docker import Docker
class Dovecot:
def __init__(self):
self.docker = Docker()
def decryptMaildir(self, source_dir="/var/vmail/", output_dir=None):
"""
Decrypt files in /var/vmail using doveadm if they are encrypted.
:param output_dir: Directory inside the Dovecot container to store decrypted files, Default overwrite.
"""
private_key = "/mail_crypt/ecprivkey.pem"
public_key = "/mail_crypt/ecpubkey.pem"
if output_dir:
# Ensure the output directory exists inside the container
mkdir_result = self.docker.exec_command("dovecot-mailcow", f"bash -c 'mkdir -p {output_dir} && chown vmail:vmail {output_dir}'")
if mkdir_result.get("status") != "success":
print(f"Error creating output directory: {mkdir_result.get('output')}")
return
find_command = [
"find", source_dir, "-type", "f", "-regextype", "egrep", "-regex", ".*S=.*W=.*"
]
try:
find_result = self.docker.exec_command("dovecot-mailcow", " ".join(find_command))
if find_result.get("status") != "success":
print(f"Error finding files: {find_result.get('output')}")
return
files = find_result.get("output", "").splitlines()
for file in files:
head_command = f"head -c7 {file}"
head_result = self.docker.exec_command("dovecot-mailcow", head_command)
if head_result.get("status") == "success" and head_result.get("output", "").strip() == "CRYPTED":
if output_dir:
# Preserve the directory structure in the output directory
relative_path = os.path.relpath(file, source_dir)
output_file = os.path.join(output_dir, relative_path)
current_path = output_dir
for part in os.path.dirname(relative_path).split(os.sep):
current_path = os.path.join(current_path, part)
mkdir_result = self.docker.exec_command("dovecot-mailcow", f"bash -c '[ ! -d {current_path} ] && mkdir {current_path} && chown vmail:vmail {current_path}'")
if mkdir_result.get("status") != "success":
print(f"Error creating directory {current_path}: {mkdir_result.get('output')}")
continue
else:
# Overwrite the original file
output_file = file
decrypt_command = (
f"bash -c 'doveadm fs get compress lz4:1:crypt:private_key_path={private_key}:public_key_path={public_key}:posix:prefix=/ {file} > {output_file}'"
)
decrypt_result = self.docker.exec_command("dovecot-mailcow", decrypt_command)
if decrypt_result.get("status") == "success":
print(f"Decrypted {file}")
# Verify the file size and set permissions
size_check_command = f"bash -c '[ -s {output_file} ] && chmod 600 {output_file} && chown vmail:vmail {output_file} || rm -f {output_file}'"
size_check_result = self.docker.exec_command("dovecot-mailcow", size_check_command)
if size_check_result.get("status") != "success":
print(f"Error setting permissions for {output_file}: {size_check_result.get('output')}\n")
except Exception as e:
print(f"Error during decryption: {e}")
return "Done"
def encryptMaildir(self, source_dir="/var/vmail/", output_dir=None):
"""
Encrypt files in /var/vmail using doveadm if they are not already encrypted.
:param source_dir: Directory inside the Dovecot container to encrypt files.
:param output_dir: Directory inside the Dovecot container to store encrypted files, Default overwrite.
"""
private_key = "/mail_crypt/ecprivkey.pem"
public_key = "/mail_crypt/ecpubkey.pem"
if output_dir:
# Ensure the output directory exists inside the container
mkdir_result = self.docker.exec_command("dovecot-mailcow", f"mkdir -p {output_dir}")
if mkdir_result.get("status") != "success":
print(f"Error creating output directory: {mkdir_result.get('output')}")
return
find_command = [
"find", source_dir, "-type", "f", "-regextype", "egrep", "-regex", ".*S=.*W=.*"
]
try:
find_result = self.docker.exec_command("dovecot-mailcow", " ".join(find_command))
if find_result.get("status") != "success":
print(f"Error finding files: {find_result.get('output')}")
return
files = find_result.get("output", "").splitlines()
for file in files:
head_command = f"head -c7 {file}"
head_result = self.docker.exec_command("dovecot-mailcow", head_command)
if head_result.get("status") == "success" and head_result.get("output", "").strip() != "CRYPTED":
if output_dir:
# Preserve the directory structure in the output directory
relative_path = os.path.relpath(file, source_dir)
output_file = os.path.join(output_dir, relative_path)
current_path = output_dir
for part in os.path.dirname(relative_path).split(os.sep):
current_path = os.path.join(current_path, part)
mkdir_result = self.docker.exec_command("dovecot-mailcow", f"bash -c '[ ! -d {current_path} ] && mkdir {current_path} && chown vmail:vmail {current_path}'")
if mkdir_result.get("status") != "success":
print(f"Error creating directory {current_path}: {mkdir_result.get('output')}")
continue
else:
# Overwrite the original file
output_file = file
encrypt_command = (
f"bash -c 'doveadm fs put crypt private_key_path={private_key}:public_key_path={public_key}:posix:prefix=/ {file} {output_file}'"
)
encrypt_result = self.docker.exec_command("dovecot-mailcow", encrypt_command)
if encrypt_result.get("status") == "success":
print(f"Encrypted {file}")
# Set permissions
permissions_command = f"bash -c 'chmod 600 {output_file} && chown 5000:5000 {output_file}'"
permissions_result = self.docker.exec_command("dovecot-mailcow", permissions_command)
if permissions_result.get("status") != "success":
print(f"Error setting permissions for {output_file}: {permissions_result.get('output')}\n")
except Exception as e:
print(f"Error during encryption: {e}")
return "Done"
def listDeletedMaildirs(self, source_dir="/var/vmail/_garbage"):
"""
List deleted maildirs in the specified garbage directory.
:param source_dir: Directory to search for deleted maildirs.
:return: List of maildirs.
"""
list_command = ["bash", "-c", f"ls -la {source_dir}"]
try:
result = self.docker.exec_command("dovecot-mailcow", list_command)
if result.get("status") != "success":
print(f"Error listing deleted maildirs: {result.get('output')}")
return []
lines = result.get("output", "").splitlines()
maildirs = {}
for idx, line in enumerate(lines):
parts = line.split()
if "_" in line:
folder_name = parts[-1]
time, maildir = folder_name.split("_", 1)
if maildir.endswith("_index"):
main_item = maildir[:-6]
if main_item in maildirs:
maildirs[main_item]["has_index"] = True
else:
maildirs[maildir] = {"item": idx, "time": time, "name": maildir, "has_index": False}
return list(maildirs.values())
except Exception as e:
print(f"Error during listing deleted maildirs: {e}")
return []
def restoreMaildir(self, username, item, source_dir="/var/vmail/_garbage"):
"""
Restore a maildir item for a specific user from the deleted maildirs.
:param username: Username to restore the item to.
:param item: Item to restore (e.g., mailbox, folder).
:param source_dir: Directory containing deleted maildirs.
:return: Response from Dovecot.
"""
username_splitted = username.split("@")
maildirs = self.listDeletedMaildirs()
maildir = None
for mdir in maildirs:
if mdir["item"] == int(item):
maildir = mdir
break
if not maildir:
return {"status": "error", "message": "Maildir not found."}
restore_command = f"mv {source_dir}/{maildir['time']}_{maildir['name']} /var/vmail/{username_splitted[1]}/{username_splitted[0]}"
restore_index_command = f"mv {source_dir}/{maildir['time']}_{maildir['name']}_index /var/vmail_index/{username}"
result = self.docker.exec_command("dovecot-mailcow", ["bash", "-c", restore_command])
if result.get("status") != "success":
return {"status": "error", "message": "Failed to restore maildir."}
result = self.docker.exec_command("dovecot-mailcow", ["bash", "-c", restore_index_command])
if result.get("status") != "success":
return {"status": "error", "message": "Failed to restore maildir index."}
return "Done"

View File

@@ -0,0 +1,457 @@
import requests
import urllib3
import sys
import os
import subprocess
import tempfile
import mysql.connector
from contextlib import contextmanager
from datetime import datetime
from modules.Docker import Docker
class Mailcow:
def __init__(self):
self.apiUrl = "/api/v1"
self.ignore_ssl_errors = True
self.baseUrl = f"https://{os.getenv('IPv4_NETWORK', '172.22.1')}.247"
self.host = os.getenv("MAILCOW_HOSTNAME", "")
self.apiKey = ""
if self.ignore_ssl_errors:
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
self.db_config = {
'user': os.getenv('DBUSER'),
'password': os.getenv('DBPASS'),
'database': os.getenv('DBNAME'),
'unix_socket': '/var/run/mysqld/mysqld.sock',
}
self.docker = Docker()
# API Functions
def addDomain(self, domain):
"""
Add a domain to the mailcow instance.
:param domain: Dictionary containing domain details.
:return: Response from the mailcow API.
"""
return self.post('/add/domain', domain)
def addMailbox(self, mailbox):
"""
Add a mailbox to the mailcow instance.
:param mailbox: Dictionary containing mailbox details.
:return: Response from the mailcow API.
"""
return self.post('/add/mailbox', mailbox)
def addAlias(self, alias):
"""
Add an alias to the mailcow instance.
:param alias: Dictionary containing alias details.
:return: Response from the mailcow API.
"""
return self.post('/add/alias', alias)
def addSyncjob(self, syncjob):
"""
Add a sync job to the mailcow instance.
:param syncjob: Dictionary containing sync job details.
:return: Response from the mailcow API.
"""
return self.post('/add/syncjob', syncjob)
def addDomainadmin(self, domainadmin):
"""
Add a domain admin to the mailcow instance.
:param domainadmin: Dictionary containing domain admin details.
:return: Response from the mailcow API.
"""
return self.post('/add/domain-admin', domainadmin)
def deleteDomain(self, domain):
"""
Delete a domain from the mailcow instance.
:param domain: Name of the domain to delete.
:return: Response from the mailcow API.
"""
items = [domain]
return self.post('/delete/domain', items)
def deleteAlias(self, id):
"""
Delete an alias from the mailcow instance.
:param id: ID of the alias to delete.
:return: Response from the mailcow API.
"""
items = [id]
return self.post('/delete/alias', items)
def deleteSyncjob(self, id):
"""
Delete a sync job from the mailcow instance.
:param id: ID of the sync job to delete.
:return: Response from the mailcow API.
"""
items = [id]
return self.post('/delete/syncjob', items)
def deleteMailbox(self, mailbox):
"""
Delete a mailbox from the mailcow instance.
:param mailbox: Name of the mailbox to delete.
:return: Response from the mailcow API.
"""
items = [mailbox]
return self.post('/delete/mailbox', items)
def deleteDomainadmin(self, username):
"""
Delete a domain admin from the mailcow instance.
:param username: Username of the domain admin to delete.
:return: Response from the mailcow API.
"""
items = [username]
return self.post('/delete/domain-admin', items)
def post(self, endpoint, data):
"""
Make a POST request to the mailcow API.
:param endpoint: The API endpoint to post to.
:param data: Data to be sent in the POST request.
:return: Response from the mailcow API.
"""
url = f"{self.baseUrl}{self.apiUrl}/{endpoint.lstrip('/')}"
headers = {
"Content-Type": "application/json",
"Host": self.host
}
if self.apiKey:
headers["X-Api-Key"] = self.apiKey
response = requests.post(
url,
json=data,
headers=headers,
verify=not self.ignore_ssl_errors
)
response.raise_for_status()
return response.json()
def getDomain(self, domain):
"""
Get a domain from the mailcow instance.
:param domain: Name of the domain to get.
:return: Response from the mailcow API.
"""
return self.get(f'/get/domain/{domain}')
def getMailbox(self, username):
"""
Get a mailbox from the mailcow instance.
:param mailbox: Dictionary containing mailbox details (e.g. {"username": "user@example.com"})
:return: Response from the mailcow API.
"""
return self.get(f'/get/mailbox/{username}')
def getAlias(self, id):
"""
Get an alias from the mailcow instance.
:param alias: Dictionary containing alias details (e.g. {"address": "alias@example.com"})
:return: Response from the mailcow API.
"""
return self.get(f'/get/alias/{id}')
def getSyncjob(self, id):
"""
Get a sync job from the mailcow instance.
:param syncjob: Dictionary containing sync job details (e.g. {"id": "123"})
:return: Response from the mailcow API.
"""
return self.get(f'/get/syncjobs/{id}')
def getDomainadmin(self, username):
"""
Get a domain admin from the mailcow instance.
:param username: Username of the domain admin to get.
:return: Response from the mailcow API.
"""
return self.get(f'/get/domain-admin/{username}')
def getStatusVersion(self):
"""
Get the version of the mailcow instance.
:return: Response from the mailcow API.
"""
return self.get('/get/status/version')
def getStatusVmail(self):
"""
Get the vmail status from the mailcow instance.
:return: Response from the mailcow API.
"""
return self.get('/get/status/vmail')
def getStatusContainers(self):
"""
Get the status of containers from the mailcow instance.
:return: Response from the mailcow API.
"""
return self.get('/get/status/containers')
def get(self, endpoint, params=None):
"""
Make a GET request to the mailcow API.
:param endpoint: The API endpoint to get from.
:param params: Parameters to be sent in the GET request.
:return: Response from the mailcow API.
"""
url = f"{self.baseUrl}{self.apiUrl}/{endpoint.lstrip('/')}"
headers = {
"Content-Type": "application/json",
"Host": self.host
}
if self.apiKey:
headers["X-Api-Key"] = self.apiKey
response = requests.get(
url,
params=params,
headers=headers,
verify=not self.ignore_ssl_errors
)
response.raise_for_status()
return response.json()
def editDomain(self, domain, attributes):
"""
Edit an existing domain in the mailcow instance.
:param domain: Name of the domain to edit
:param attributes: Dictionary containing the new domain attributes.
"""
items = [domain]
return self.edit('/edit/domain', items, attributes)
def editMailbox(self, mailbox, attributes):
"""
Edit an existing mailbox in the mailcow instance.
:param mailbox: Name of the mailbox to edit
:param attributes: Dictionary containing the new mailbox attributes.
"""
items = [mailbox]
return self.edit('/edit/mailbox', items, attributes)
def editAlias(self, alias, attributes):
"""
Edit an existing alias in the mailcow instance.
:param alias: Name of the alias to edit
:param attributes: Dictionary containing the new alias attributes.
"""
items = [alias]
return self.edit('/edit/alias', items, attributes)
def editSyncjob(self, syncjob, attributes):
"""
Edit an existing sync job in the mailcow instance.
:param syncjob: Name of the sync job to edit
:param attributes: Dictionary containing the new sync job attributes.
"""
items = [syncjob]
return self.edit('/edit/syncjob', items, attributes)
def editDomainadmin(self, username, attributes):
"""
Edit an existing domain admin in the mailcow instance.
:param username: Username of the domain admin to edit
:param attributes: Dictionary containing the new domain admin attributes.
"""
items = [username]
return self.edit('/edit/domain-admin', items, attributes)
def edit(self, endpoint, items, attributes):
"""
Make a POST request to edit items in the mailcow API.
:param items: List of items to edit.
:param attributes: Dictionary containing the new attributes for the items.
:return: Response from the mailcow API.
"""
url = f"{self.baseUrl}{self.apiUrl}/{endpoint.lstrip('/')}"
headers = {
"Content-Type": "application/json",
"Host": self.host
}
if self.apiKey:
headers["X-Api-Key"] = self.apiKey
data = {
"items": items,
"attr": attributes
}
response = requests.post(
url,
json=data,
headers=headers,
verify=not self.ignore_ssl_errors
)
response.raise_for_status()
return response.json()
# System Functions
def runSyncjob(self, id, force=False):
"""
Run a sync job.
:param id: ID of the sync job to run.
:return: Response from the imapsync script.
"""
creds_path = "/app/sieve.creds"
conn = mysql.connector.connect(**self.db_config)
cursor = conn.cursor(dictionary=True)
with open(creds_path, 'r') as file:
master_user, master_pass = file.read().strip().split(':')
query = ("SELECT * FROM imapsync WHERE id = %s")
cursor.execute(query, (id,))
success = False
syncjob = cursor.fetchone()
if not syncjob:
cursor.close()
conn.close()
return f"Sync job with ID {id} not found."
if syncjob['active'] == 0 and not force:
cursor.close()
conn.close()
return f"Sync job with ID {id} is not active."
enc1_flag = "--tls1" if syncjob['enc1'] == "TLS" else "--ssl1" if syncjob['enc1'] == "SSL" else None
passfile1_path = f"/tmp/passfile1_{id}.txt"
passfile2_path = f"/tmp/passfile2_{id}.txt"
passfile1_cmd = [
"sh", "-c",
f"echo {syncjob['password1']} > {passfile1_path}"
]
passfile2_cmd = [
"sh", "-c",
f"echo {master_pass} > {passfile2_path}"
]
self.docker.exec_command("dovecot-mailcow", passfile1_cmd)
self.docker.exec_command("dovecot-mailcow", passfile2_cmd)
imapsync_cmd = [
"/usr/local/bin/imapsync",
"--tmpdir", "/tmp",
"--nofoldersizes",
"--addheader"
]
if int(syncjob['timeout1']) > 0:
imapsync_cmd.extend(['--timeout1', str(syncjob['timeout1'])])
if int(syncjob['timeout2']) > 0:
imapsync_cmd.extend(['--timeout2', str(syncjob['timeout2'])])
if syncjob['exclude']:
imapsync_cmd.extend(['--exclude', syncjob['exclude']])
if syncjob['subfolder2']:
imapsync_cmd.extend(['--subfolder2', syncjob['subfolder2']])
if int(syncjob['maxage']) > 0:
imapsync_cmd.extend(['--maxage', str(syncjob['maxage'])])
if int(syncjob['maxbytespersecond']) > 0:
imapsync_cmd.extend(['--maxbytespersecond', str(syncjob['maxbytespersecond'])])
if int(syncjob['delete2duplicates']) == 1:
imapsync_cmd.append("--delete2duplicates")
if int(syncjob['subscribeall']) == 1:
imapsync_cmd.append("--subscribeall")
if int(syncjob['delete1']) == 1:
imapsync_cmd.append("--delete")
if int(syncjob['delete2']) == 1:
imapsync_cmd.append("--delete2")
if int(syncjob['automap']) == 1:
imapsync_cmd.append("--automap")
if int(syncjob['skipcrossduplicates']) == 1:
imapsync_cmd.append("--skipcrossduplicates")
if enc1_flag:
imapsync_cmd.append(enc1_flag)
imapsync_cmd.extend([
"--host1", syncjob['host1'],
"--user1", syncjob['user1'],
"--passfile1", passfile1_path,
"--port1", str(syncjob['port1']),
"--host2", "localhost",
"--user2", f"{syncjob['user2']}*{master_user}",
"--passfile2", passfile2_path
])
if syncjob['dry'] == 1:
imapsync_cmd.append("--dry")
imapsync_cmd.extend([
"--no-modulesversion",
"--noreleasecheck"
])
try:
cursor.execute("UPDATE imapsync SET is_running = 1, success = NULL, exit_status = NULL WHERE id = %s", (id,))
conn.commit()
result = self.docker.exec_command("dovecot-mailcow", imapsync_cmd)
print(result)
success = result['status'] == "success" and result['exit_code'] == 0
cursor.execute(
"UPDATE imapsync SET returned_text = %s, success = %s, exit_status = %s WHERE id = %s",
(result['output'], int(success), result['exit_code'], id)
)
conn.commit()
except Exception as e:
cursor.execute(
"UPDATE imapsync SET returned_text = %s, success = 0 WHERE id = %s",
(str(e), id)
)
conn.commit()
finally:
cursor.execute("UPDATE imapsync SET last_run = NOW(), is_running = 0 WHERE id = %s", (id,))
conn.commit()
delete_passfile1_cmd = [
"sh", "-c",
f"rm -f {passfile1_path}"
]
delete_passfile2_cmd = [
"sh", "-c",
f"rm -f {passfile2_path}"
]
self.docker.exec_command("dovecot-mailcow", delete_passfile1_cmd)
self.docker.exec_command("dovecot-mailcow", delete_passfile2_cmd)
cursor.close()
conn.close()
return "Sync job completed successfully." if success else "Sync job failed."

View File

@@ -0,0 +1,64 @@
import smtplib
import json
import os
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
from jinja2 import Environment, BaseLoader
class Mailer:
def __init__(self, smtp_host, smtp_port, username, password, use_tls=True):
self.smtp_host = smtp_host
self.smtp_port = smtp_port
self.username = username
self.password = password
self.use_tls = use_tls
self.server = None
self.env = Environment(loader=BaseLoader())
def connect(self):
print("Connecting to the SMTP server...")
self.server = smtplib.SMTP(self.smtp_host, self.smtp_port)
if self.use_tls:
self.server.starttls()
print("TLS activated!")
if self.username and self.password:
self.server.login(self.username, self.password)
print("Authenticated!")
def disconnect(self):
if self.server:
try:
if self.server.sock:
self.server.quit()
except smtplib.SMTPServerDisconnected:
pass
finally:
self.server = None
def render_inline_template(self, template_string, context):
template = self.env.from_string(template_string)
return template.render(context)
def send_mail(self, subject, from_addr, to_addrs, template, context = {}):
try:
if template == "":
print("Cannot send email, template is empty!")
return "Failed: Template is empty."
body = self.render_inline_template(template, context)
msg = MIMEMultipart()
msg['From'] = from_addr
msg['To'] = ', '.join(to_addrs) if isinstance(to_addrs, list) else to_addrs
msg['Subject'] = subject
msg.attach(MIMEText(body, 'html'))
self.connect()
self.server.sendmail(from_addr, to_addrs, msg.as_string())
self.disconnect()
return f"Success: Email sent to {msg['To']}"
except Exception as e:
print(f"Error during send_mail: {type(e).__name__}: {e}")
return f"Failed: {type(e).__name__}: {e}"
finally:
self.disconnect()

View File

@@ -0,0 +1,51 @@
from jinja2 import Environment, Template
import csv
def split_at(value, sep, idx):
try:
return value.split(sep)[idx]
except Exception:
return ''
class Reader:
"""
Reader class to handle reading and processing of CSV and JSON files for mailcow.
"""
def __init__(self):
pass
def read_csv(self, file_path, delimiter=',', encoding='iso-8859-1'):
"""
Read a CSV file and return a list of dictionaries.
Each dictionary represents a row in the CSV file.
:param file_path: Path to the CSV file.
:param delimiter: Delimiter used in the CSV file (default: ',').
"""
with open(file_path, mode='r', encoding=encoding) as file:
reader = csv.DictReader(file, delimiter=delimiter)
reader.fieldnames = [h.replace(" ", "_") if h else h for h in reader.fieldnames]
return [row for row in reader]
def map_csv_data(self, data, mapping_file_path, encoding='iso-8859-1'):
"""
Map CSV data to a specific structure based on the provided Jinja2 template file.
:param data: List of dictionaries representing CSV rows.
:param mapping_file_path: Path to the Jinja2 template file.
:return: List of dictionaries with mapped data.
"""
with open(mapping_file_path, 'r', encoding=encoding) as tpl_file:
template_content = tpl_file.read()
env = Environment()
env.filters['split_at'] = split_at
template = env.from_string(template_content)
mapped_data = []
for row in data:
rendered = template.render(**row)
try:
mapped_row = eval(rendered)
except Exception:
mapped_row = rendered
mapped_data.append(mapped_row)
return mapped_data

View File

@@ -0,0 +1,512 @@
import requests
import urllib3
import os
from uuid import uuid4
from collections import defaultdict
class Sogo:
def __init__(self, username, password=""):
self.apiUrl = "/SOGo/so"
self.davUrl = "/SOGo/dav"
self.ignore_ssl_errors = True
self.baseUrl = f"https://{os.getenv('IPv4_NETWORK', '172.22.1')}.247"
self.host = os.getenv("MAILCOW_HOSTNAME", "")
if self.ignore_ssl_errors:
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
self.username = username
self.password = password
def addCalendar(self, calendar_name):
"""
Add a new calendar to the sogo instance.
:param calendar_name: Name of the calendar to be created
:return: Response from the sogo API.
"""
res = self.post(f"/{self.username}/Calendar/createFolder", {
"name": calendar_name
})
try:
return res.json()
except ValueError:
return res.text
def getCalendarIdByName(self, calendar_name):
"""
Get the calendar ID by its name.
:param calendar_name: Name of the calendar to find
:return: Calendar ID if found, otherwise None.
"""
res = self.get(f"/{self.username}/Calendar/calendarslist")
try:
for calendar in res.json()["calendars"]:
if calendar['name'] == calendar_name:
return calendar['id']
except ValueError:
return None
return None
def getCalendar(self):
"""
Get calendar list.
:return: Response from SOGo API.
"""
res = self.get(f"/{self.username}/Calendar/calendarslist")
try:
return res.json()
except ValueError:
return res.text
def deleteCalendar(self, calendar_id):
"""
Delete a calendar.
:param calendar_id: ID of the calendar to be deleted
:return: Response from SOGo API.
"""
res = self.get(f"/{self.username}/Calendar/{calendar_id}/delete")
return res.status_code == 204
def importCalendar(self, calendar_name, ics_file):
"""
Import a calendar from an ICS file.
:param calendar_name: Name of the calendar to import into
:param ics_file: Path to the ICS file to import
:return: Response from SOGo API.
"""
try:
with open(ics_file, "rb") as f:
pass
except Exception as e:
print(f"Could not open ICS file '{ics_file}': {e}")
return {"status": "error", "message": str(e)}
new_calendar = self.addCalendar(calendar_name)
selected_calendar = new_calendar.json()["id"]
url = f"{self.baseUrl}{self.apiUrl}/{self.username}/Calendar/{selected_calendar}/import"
auth = (self.username, self.password)
with open(ics_file, "rb") as f:
files = {'icsFile': (ics_file, f, 'text/calendar')}
res = requests.post(
url,
files=files,
auth=auth,
verify=not self.ignore_ssl_errors
)
try:
return res.json()
except ValueError:
return res.text
return None
def setCalendarACL(self, calendar_id, sharee_email, acl="r", subscribe=False):
"""
Set CalDAV calendar permissions for a user (sharee).
:param calendar_id: ID of the calendar to share
:param sharee_email: Email of the user to share with
:param acl: "w" for write, "r" for read-only or combination "rw" for read-write
:param subscribe: True will scubscribe the sharee to the calendar
:return: None
"""
# Access rights
if acl == "" or len(acl) > 2:
return "Invalid acl level specified. Use 'w', 'r' or combinations like 'rw'."
rights = [{
"c_email": sharee_email,
"uid": sharee_email,
"userClass": "normal-user",
"rights": {
"Public": "None",
"Private": "None",
"Confidential": "None",
"canCreateObjects": 0,
"canEraseObjects": 0
}
}]
if "w" in acl:
rights[0]["rights"]["canCreateObjects"] = 1
rights[0]["rights"]["canEraseObjects"] = 1
if "r" in acl:
rights[0]["rights"]["Public"] = "Viewer"
rights[0]["rights"]["Private"] = "Viewer"
rights[0]["rights"]["Confidential"] = "Viewer"
r_add = self.get(f"/{self.username}/Calendar/{calendar_id}/addUserInAcls?uid={sharee_email}")
if r_add.status_code < 200 or r_add.status_code > 299:
try:
return r_add.json()
except ValueError:
return r_add.text
r_save = self.post(f"/{self.username}/Calendar/{calendar_id}/saveUserRights", rights)
if r_save.status_code < 200 or r_save.status_code > 299:
try:
return r_save.json()
except ValueError:
return r_save.text
if subscribe:
r_subscribe = self.get(f"/{self.username}/Calendar/{calendar_id}/subscribeUsers?uids={sharee_email}")
if r_subscribe.status_code < 200 or r_subscribe.status_code > 299:
try:
return r_subscribe.json()
except ValueError:
return r_subscribe.text
return r_save.status_code == 200
def getCalendarACL(self, calendar_id):
"""
Get CalDAV calendar permissions for a user (sharee).
:param calendar_id: ID of the calendar to get ACL from
:return: Response from SOGo API.
"""
res = self.get(f"/{self.username}/Calendar/{calendar_id}/acls")
try:
return res.json()
except ValueError:
return res.text
def deleteCalendarACL(self, calendar_id, sharee_email):
"""
Delete a calendar ACL for a user (sharee).
:param calendar_id: ID of the calendar to delete ACL from
:param sharee_email: Email of the user whose ACL to delete
:return: Response from SOGo API.
"""
res = self.get(f"/{self.username}/Calendar/{calendar_id}/removeUserFromAcls?uid={sharee_email}")
return res.status_code == 204
def addAddressbook(self, addressbook_name):
"""
Add a new addressbook to the sogo instance.
:param addressbook_name: Name of the addressbook to be created
:return: Response from the sogo API.
"""
res = self.post(f"/{self.username}/Contacts/createFolder", {
"name": addressbook_name
})
try:
return res.json()
except ValueError:
return res.text
def getAddressbookIdByName(self, addressbook_name):
"""
Get the addressbook ID by its name.
:param addressbook_name: Name of the addressbook to find
:return: Addressbook ID if found, otherwise None.
"""
res = self.get(f"/{self.username}/Contacts/addressbooksList")
try:
for addressbook in res.json()["addressbooks"]:
if addressbook['name'] == addressbook_name:
return addressbook['id']
except ValueError:
return None
return None
def deleteAddressbook(self, addressbook_id):
"""
Delete an addressbook.
:param addressbook_id: ID of the addressbook to be deleted
:return: Response from SOGo API.
"""
res = self.get(f"/{self.username}/Contacts/{addressbook_id}/delete")
return res.status_code == 204
def getAddressbookList(self):
"""
Get addressbook list.
:return: Response from SOGo API.
"""
res = self.get(f"/{self.username}/Contacts/addressbooksList")
try:
return res.json()
except ValueError:
return res.text
def setAddressbookACL(self, addressbook_id, sharee_email, acl="r", subscribe=False):
"""
Set CalDAV addressbook permissions for a user (sharee).
:param addressbook_id: ID of the addressbook to share
:param sharee_email: Email of the user to share with
:param acl: "w" for write, "r" for read-only or combination "rw" for read-write
:param subscribe: True will subscribe the sharee to the addressbook
:return: None
"""
# Access rights
if acl == "" or len(acl) > 2:
print("Invalid acl level specified. Use 's', 'w', 'r' or combinations like 'rws'.")
return "Invalid acl level specified. Use 'w', 'r' or combinations like 'rw'."
rights = [{
"c_email": sharee_email,
"uid": sharee_email,
"userClass": "normal-user",
"rights": {
"canCreateObjects": 0,
"canEditObjects": 0,
"canEraseObjects": 0,
"canViewObjects": 0,
}
}]
if "w" in acl:
rights[0]["rights"]["canCreateObjects"] = 1
rights[0]["rights"]["canEditObjects"] = 1
rights[0]["rights"]["canEraseObjects"] = 1
if "r" in acl:
rights[0]["rights"]["canViewObjects"] = 1
r_add = self.get(f"/{self.username}/Contacts/{addressbook_id}/addUserInAcls?uid={sharee_email}")
if r_add.status_code < 200 or r_add.status_code > 299:
try:
return r_add.json()
except ValueError:
return r_add.text
r_save = self.post(f"/{self.username}/Contacts/{addressbook_id}/saveUserRights", rights)
if r_save.status_code < 200 or r_save.status_code > 299:
try:
return r_save.json()
except ValueError:
return r_save.text
if subscribe:
r_subscribe = self.get(f"/{self.username}/Contacts/{addressbook_id}/subscribeUsers?uids={sharee_email}")
if r_subscribe.status_code < 200 or r_subscribe.status_code > 299:
try:
return r_subscribe.json()
except ValueError:
return r_subscribe.text
return r_save.status_code == 200
def getAddressbookACL(self, addressbook_id):
"""
Get CalDAV addressbook permissions for a user (sharee).
:param addressbook_id: ID of the addressbook to get ACL from
:return: Response from SOGo API.
"""
res = self.get(f"/{self.username}/Contacts/{addressbook_id}/acls")
try:
return res.json()
except ValueError:
return res.text
def deleteAddressbookACL(self, addressbook_id, sharee_email):
"""
Delete an addressbook ACL for a user (sharee).
:param addressbook_id: ID of the addressbook to delete ACL from
:param sharee_email: Email of the user whose ACL to delete
:return: Response from SOGo API.
"""
res = self.get(f"/{self.username}/Contacts/{addressbook_id}/removeUserFromAcls?uid={sharee_email}")
return res.status_code == 204
def getAddressbookNewGuid(self, addressbook_id):
"""
Request a new GUID for a SOGo addressbook.
:param addressbook_id: ID of the addressbook
:return: JSON response from SOGo or None if not found
"""
res = self.get(f"/{self.username}/Contacts/{addressbook_id}/newguid")
try:
return res.json()
except ValueError:
return res.text
def addAddressbookContact(self, addressbook_id, contact_name, contact_email):
"""
Save a vCard as a contact in the specified addressbook.
:param addressbook_id: ID of the addressbook
:param contact_name: Name of the contact
:param contact_email: Email of the contact
:return: JSON response from SOGo or None if not found
"""
vcard_id = self.getAddressbookNewGuid(addressbook_id)
contact_data = {
"id": vcard_id["id"],
"pid": vcard_id["pid"],
"c_cn": contact_name,
"emails": [{
"type": "pref",
"value": contact_email
}],
"isNew": True,
"c_component": "vcard",
}
endpoint = f"/{self.username}/Contacts/{addressbook_id}/{vcard_id['id']}/saveAsContact"
res = self.post(endpoint, contact_data)
try:
return res.json()
except ValueError:
return res.text
def getAddressbookContacts(self, addressbook_id, contact_email=None):
"""
Get all contacts from the specified addressbook.
:param addressbook_id: ID of the addressbook
:return: JSON response with contacts or None if not found
"""
res = self.get(f"/{self.username}/Contacts/{addressbook_id}/view")
try:
res_json = res.json()
headers = res_json.get("headers", [])
if not headers or len(headers) < 2:
return []
field_names = headers[0]
contacts = []
for row in headers[1:]:
contact = dict(zip(field_names, row))
contacts.append(contact)
if contact_email:
contact = {}
for c in contacts:
if c["c_mail"] == contact_email or c["c_cn"] == contact_email:
contact = c
break
return contact
return contacts
except ValueError:
return res.text
def addAddressbookContactList(self, addressbook_id, contact_name, contact_email=None):
"""
Add a new contact list to the addressbook.
:param addressbook_id: ID of the addressbook
:param contact_name: Name of the contact list
:param contact_email: Comma-separated emails to include in the list
:return: Response from SOGo API.
"""
gal_domain = self.username.split("@")[-1]
vlist_id = self.getAddressbookNewGuid(addressbook_id)
contact_emails = contact_email.split(",") if contact_email else []
contacts = self.getAddressbookContacts(addressbook_id)
refs = []
for contact in contacts:
if contact['c_mail'] in contact_emails:
refs.append({
"refs": [],
"categories": [],
"c_screenname": contact.get("c_screenname", ""),
"pid": contact.get("pid", vlist_id["pid"]),
"id": contact.get("id", ""),
"notes": [""],
"empty": " ",
"hasphoto": contact.get("hasphoto", 0),
"c_cn": contact.get("c_cn", ""),
"c_uid": contact.get("c_uid", None),
"containername": contact.get("containername", f"GAL {gal_domain}"), # or your addressbook name
"sourceid": contact.get("sourceid", gal_domain),
"c_component": contact.get("c_component", "vcard"),
"c_sn": contact.get("c_sn", ""),
"c_givenname": contact.get("c_givenname", ""),
"c_name": contact.get("c_name", contact.get("id", "")),
"c_telephonenumber": contact.get("c_telephonenumber", ""),
"fn": contact.get("fn", ""),
"c_mail": contact.get("c_mail", ""),
"emails": contact.get("emails", []),
"c_o": contact.get("c_o", ""),
"reference": contact.get("id", ""),
"birthday": contact.get("birthday", "")
})
contact_data = {
"refs": refs,
"categories": [],
"c_screenname": None,
"pid": vlist_id["pid"],
"c_component": "vlist",
"notes": [""],
"empty": " ",
"isNew": True,
"id": vlist_id["id"],
"c_cn": contact_name,
"birthday": ""
}
endpoint = f"/{self.username}/Contacts/{addressbook_id}/{vlist_id['id']}/saveAsList"
res = self.post(endpoint, contact_data)
try:
return res.json()
except ValueError:
return res.text
def deleteAddressbookItem(self, addressbook_id, contact_name):
"""
Delete an addressbook item by its ID.
:param addressbook_id: ID of the addressbook item to delete
:param contact_name: Name of the contact to delete
:return: Response from SOGo API.
"""
res = self.getAddressbookContacts(addressbook_id, contact_name)
if "id" not in res:
print(f"Contact '{contact_name}' not found in addressbook '{addressbook_id}'.")
return None
res = self.post(f"/{self.username}/Contacts/{addressbook_id}/batchDelete", {
"uids": [res["id"]],
})
return res.status_code == 204
def get(self, endpoint, params=None):
"""
Make a GET request to the mailcow API.
:param endpoint: The API endpoint to get.
:param params: Optional parameters for the GET request.
:return: Response from the mailcow API.
"""
url = f"{self.baseUrl}{self.apiUrl}{endpoint}"
auth = (self.username, self.password)
headers = {"Host": self.host}
response = requests.get(
url,
params=params,
auth=auth,
headers=headers,
verify=not self.ignore_ssl_errors
)
return response
def post(self, endpoint, data):
"""
Make a POST request to the mailcow API.
:param endpoint: The API endpoint to post to.
:param data: Data to be sent in the POST request.
:return: Response from the mailcow API.
"""
url = f"{self.baseUrl}{self.apiUrl}{endpoint}"
auth = (self.username, self.password)
headers = {"Host": self.host}
response = requests.post(
url,
json=data,
auth=auth,
headers=headers,
verify=not self.ignore_ssl_errors
)
return response

View File

@@ -0,0 +1,37 @@
import json
import random
import string
class Utils:
def __init(self):
pass
def normalize_email(self, email):
replacements = {
"ä": "ae", "ö": "oe", "ü": "ue", "ß": "ss",
"Ä": "Ae", "Ö": "Oe", "Ü": "Ue"
}
for orig, repl in replacements.items():
email = email.replace(orig, repl)
return email
def generate_password(self, length=8):
chars = string.ascii_letters + string.digits
return ''.join(random.choices(chars, k=length))
def pprint(self, data=""):
"""
Pretty print a dictionary, list, or text.
If data is a text containing JSON, it will be printed in a formatted way.
"""
if isinstance(data, (dict, list)):
print(json.dumps(data, indent=2, ensure_ascii=False))
elif isinstance(data, str):
try:
json_data = json.loads(data)
print(json.dumps(json_data, indent=2, ensure_ascii=False))
except json.JSONDecodeError:
print(data)
else:
print(data)