Convert iRedMail from REST API to direct MySQL (vmail DB) + per-company integration

- Replace IRedMailClient REST API class with direct MySQL/PDO connection to vmail database
- Move iRedMail config from global config table to per-company integrations (like Zammad)
- Add iRedMail integration card to API settings with DB host/name/user/password/port fields
- Add iRedMail checkbox to integrations section in company settings
- Change Hallinta tab visibility: show for admins (not just superadmins) when module enabled
- API endpoints now use requireCompany() + requireSuperAdmin() and get config from integrations
- Password hashing uses SSHA512 (iRedMail default)
- Mask db_password in API responses (like token masking for Zammad)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-13 21:31:23 +02:00
parent 50f34ac37b
commit f24123be81
3 changed files with 302 additions and 233 deletions

359
api.php
View File

@@ -905,140 +905,190 @@ class ImapClient {
// ==================== IREDMAIL CLIENT ====================
class IRedMailClient {
private string $baseUrl;
private string $adminEmail;
private string $adminPassword;
private ?string $cookie = null;
private \PDO $pdo;
public function __construct(string $baseUrl, string $adminEmail, string $adminPassword) {
$this->baseUrl = rtrim($baseUrl, '/');
if (!preg_match('#^https?://#i', $this->baseUrl)) {
$this->baseUrl = 'https://' . $this->baseUrl;
}
$this->adminEmail = $adminEmail;
$this->adminPassword = $adminPassword;
}
public function login(): void {
$ch = curl_init($this->baseUrl . '/api/login');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => http_build_query([
'username' => $this->adminEmail,
'password' => $this->adminPassword,
]),
CURLOPT_TIMEOUT => 10,
CURLOPT_CONNECTTIMEOUT => 5,
CURLOPT_HEADER => true,
CURLOPT_SSL_VERIFYPEER => false,
public function __construct(string $host, string $dbName, string $dbUser, string $dbPassword, int $port = 3306) {
$dsn = "mysql:host={$host};port={$port};dbname={$dbName};charset=utf8mb4";
$this->pdo = new \PDO($dsn, $dbUser, $dbPassword, [
\PDO::ATTR_ERRMODE => \PDO::ERRMODE_EXCEPTION,
\PDO::ATTR_DEFAULT_FETCH_MODE => \PDO::FETCH_ASSOC,
\PDO::ATTR_TIMEOUT => 5,
]);
$response = curl_exec($ch);
$headerSize = curl_getinfo($ch, CURLINFO_HEADER_SIZE);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$headers = substr($response, 0, $headerSize);
curl_close($ch);
}
if ($httpCode >= 400) {
throw new \RuntimeException('iRedMail kirjautuminen epäonnistui (HTTP ' . $httpCode . ')');
}
public function getDomains(): array {
$stmt = $this->pdo->query("SELECT d.domain, d.active, d.created,
(SELECT COUNT(*) FROM mailbox m WHERE m.domain = d.domain) AS mailboxes,
(SELECT COUNT(*) FROM alias a WHERE a.domain = d.domain AND a.address != a.goto AND a.address NOT IN (SELECT username FROM mailbox WHERE domain = d.domain)) AS aliases,
d.settings
FROM domain d WHERE d.domain != 'localhost' ORDER BY d.domain");
return $stmt->fetchAll();
}
// Parse Set-Cookie header
if (preg_match('/Set-Cookie:\s*([^;\r\n]+)/i', $headers, $m)) {
$this->cookie = $m[1];
} else {
throw new \RuntimeException('iRedMail: session-cookie puuttuu vastauksesta');
public function createDomain(string $domain, array $opts = []): void {
$stmt = $this->pdo->prepare("INSERT INTO domain (domain, transport, settings, created, active) VALUES (?, 'dovecot', ?, NOW(), 1)");
$settings = '';
if (!empty($opts['quota'])) $settings = 'default_user_quota:' . intval($opts['quota']) . ';';
$stmt->execute([$domain, $settings]);
}
public function deleteDomain(string $domain): void {
$this->pdo->beginTransaction();
try {
$this->pdo->prepare("DELETE FROM alias WHERE domain = ?")->execute([$domain]);
$this->pdo->prepare("DELETE FROM forwardings WHERE domain = ?")->execute([$domain]);
$this->pdo->prepare("DELETE FROM mailbox WHERE domain = ?")->execute([$domain]);
$this->pdo->prepare("DELETE FROM domain WHERE domain = ?")->execute([$domain]);
$this->pdo->commit();
} catch (\Throwable $e) {
$this->pdo->rollBack();
throw $e;
}
}
public function request(string $method, string $endpoint, ?array $data = null, bool $retried = false): array {
if (!$this->cookie) $this->login();
$url = $this->baseUrl . '/api/' . ltrim($endpoint, '/');
$ch = curl_init($url);
$opts = [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 15,
CURLOPT_CONNECTTIMEOUT => 5,
CURLOPT_HTTPHEADER => ['Cookie: ' . $this->cookie],
CURLOPT_SSL_VERIFYPEER => false,
];
if ($method === 'POST') {
$opts[CURLOPT_POST] = true;
if ($data) $opts[CURLOPT_POSTFIELDS] = http_build_query($data);
} elseif ($method === 'PUT') {
$opts[CURLOPT_CUSTOMREQUEST] = 'PUT';
if ($data) $opts[CURLOPT_POSTFIELDS] = http_build_query($data);
} elseif ($method === 'DELETE') {
$opts[CURLOPT_CUSTOMREQUEST] = 'DELETE';
}
curl_setopt_array($ch, $opts);
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
// Retry login on 401
if ($httpCode === 401 && !$retried) {
$this->cookie = null;
return $this->request($method, $endpoint, $data, true);
}
$result = json_decode($response, true) ?: [];
if ($httpCode >= 400 || (isset($result['_success']) && !$result['_success'])) {
throw new \RuntimeException('iRedMail API: ' . ($result['_msg'] ?? "HTTP $httpCode"));
}
return $result;
}
public function getDomains(): array { return $this->request('GET', 'domains'); }
public function createDomain(string $domain, array $opts = []): array {
return $this->request('POST', 'domain/' . urlencode($domain), $opts);
}
public function deleteDomain(string $domain): array {
return $this->request('DELETE', 'domain/' . urlencode($domain));
}
public function getUsers(string $domain): array {
return $this->request('GET', 'users/' . urlencode($domain));
$stmt = $this->pdo->prepare("SELECT username AS email, name AS cn, quota AS mailQuota, active,
CASE WHEN active = 1 THEN 'active' ELSE 'disabled' END AS accountStatus
FROM mailbox WHERE domain = ? ORDER BY username");
$stmt->execute([$domain]);
return $stmt->fetchAll();
}
public function getUser(string $email): array {
return $this->request('GET', 'user/' . urlencode($email));
public function createUser(string $email, string $password, array $opts = []): void {
$parts = explode('@', $email, 2);
if (count($parts) !== 2) throw new \RuntimeException('Virheellinen sähköpostiosoite');
[$local, $domain] = $parts;
// Tarkista onko domain olemassa
$stmt = $this->pdo->prepare("SELECT domain FROM domain WHERE domain = ?");
$stmt->execute([$domain]);
if (!$stmt->fetch()) throw new \RuntimeException("Domain {$domain} ei ole olemassa");
// Tarkista duplikaatti
$stmt = $this->pdo->prepare("SELECT username FROM mailbox WHERE username = ?");
$stmt->execute([$email]);
if ($stmt->fetch()) throw new \RuntimeException("Tili {$email} on jo olemassa");
$hash = $this->hashPassword($password);
$quota = intval($opts['mailQuota'] ?? 1024) * 1048576; // MB → bytes
$name = $opts['cn'] ?? '';
$maildir = $domain . '/' . $local . '/';
$this->pdo->beginTransaction();
try {
$stmt = $this->pdo->prepare("INSERT INTO mailbox (username, password, name, storagebasedirectory, storagenode, maildir, quota, domain, active, enablesmtp, enablepop3, enableimap, enabledeliver, enablelda, created) VALUES (?, ?, ?, '/var/vmail', 'vmail1', ?, ?, ?, 1, 1, 1, 1, 1, 1, NOW())");
$stmt->execute([$email, $hash, $name, $maildir, $quota, $domain]);
// Lisää myös alias-rivi (iRedMail vaatii)
$stmt = $this->pdo->prepare("INSERT INTO alias (address, goto, domain, active) VALUES (?, ?, ?, 1) ON DUPLICATE KEY UPDATE goto = VALUES(goto)");
$stmt->execute([$email, $email, $domain]);
// Lisää forwardings-rivi
$stmt = $this->pdo->prepare("INSERT INTO forwardings (address, forwarding, domain, dest_domain, is_forwarding, active) VALUES (?, ?, ?, ?, 0, 1) ON DUPLICATE KEY UPDATE forwarding = VALUES(forwarding)");
$stmt->execute([$email, $email, $domain, $domain]);
$this->pdo->commit();
} catch (\Throwable $e) {
$this->pdo->rollBack();
throw $e;
}
}
public function createUser(string $email, string $password, array $opts = []): array {
$opts['password'] = $password;
return $this->request('POST', 'user/' . urlencode($email), $opts);
public function updateUser(string $email, array $opts): void {
$sets = [];
$params = [];
if (!empty($opts['password'])) {
$sets[] = 'password = ?';
$params[] = $this->hashPassword($opts['password']);
$sets[] = 'passwordlastchange = NOW()';
}
if (isset($opts['cn'])) {
$sets[] = 'name = ?';
$params[] = $opts['cn'];
}
if (isset($opts['mailQuota'])) {
$sets[] = 'quota = ?';
$params[] = intval($opts['mailQuota']) * 1048576;
}
if (isset($opts['accountStatus'])) {
$sets[] = 'active = ?';
$params[] = $opts['accountStatus'] === 'disabled' ? 0 : 1;
}
if (empty($sets)) return;
$sets[] = 'modified = NOW()';
$params[] = $email;
$sql = "UPDATE mailbox SET " . implode(', ', $sets) . " WHERE username = ?";
$this->pdo->prepare($sql)->execute($params);
}
public function updateUser(string $email, array $opts): array {
return $this->request('PUT', 'user/' . urlencode($email), $opts);
}
public function deleteUser(string $email): array {
return $this->request('DELETE', 'user/' . urlencode($email));
public function deleteUser(string $email): void {
$domain = substr($email, strpos($email, '@') + 1);
$this->pdo->beginTransaction();
try {
$this->pdo->prepare("DELETE FROM mailbox WHERE username = ?")->execute([$email]);
$this->pdo->prepare("DELETE FROM alias WHERE address = ?")->execute([$email]);
$this->pdo->prepare("DELETE FROM forwardings WHERE address = ? OR forwarding = ?")->execute([$email, $email]);
$this->pdo->commit();
} catch (\Throwable $e) {
$this->pdo->rollBack();
throw $e;
}
}
public function getAliases(string $domain): array {
return $this->request('GET', 'aliases/' . urlencode($domain));
// Aliakset = alias-rivit jotka eivät ole mailbox-käyttäjien omia
$stmt = $this->pdo->prepare("SELECT a.address, a.goto, a.domain, a.active
FROM alias a WHERE a.domain = ?
AND a.address NOT IN (SELECT username FROM mailbox WHERE domain = ?)
ORDER BY a.address");
$stmt->execute([$domain, $domain]);
$results = $stmt->fetchAll();
return array_map(function($a) {
return [
'address' => $a['address'],
'goto' => $a['goto'],
'members' => $a['goto'],
'active' => $a['active'],
];
}, $results);
}
public function createAlias(string $alias, array $opts = []): array {
return $this->request('POST', 'alias/' . urlencode($alias), $opts);
public function createAlias(string $alias, string $goto): void {
$domain = substr($alias, strpos($alias, '@') + 1);
$stmt = $this->pdo->prepare("INSERT INTO alias (address, goto, domain, active, created) VALUES (?, ?, ?, 1, NOW()) ON DUPLICATE KEY UPDATE goto = VALUES(goto), modified = NOW()");
$stmt->execute([$alias, $goto, $domain]);
}
public function deleteAlias(string $alias): array {
return $this->request('DELETE', 'alias/' . urlencode($alias));
public function deleteAlias(string $alias): void {
$this->pdo->prepare("DELETE FROM alias WHERE address = ?")->execute([$alias]);
}
public function testConnection(): array {
$domains = $this->getDomains();
return ['ok' => true, 'domains' => count($domains['_data'] ?? [])];
return ['ok' => true, 'domains' => count($domains)];
}
private function hashPassword(string $password): string {
// SSHA512 — iRedMail default
$salt = random_bytes(8);
$hash = hash('sha512', $password . $salt, true);
return '{SSHA512}' . base64_encode($hash . $salt);
}
}
function getIRedMailClient(): IRedMailClient {
$config = dbLoadConfig();
$url = $config['iredmail_api_url'] ?? '';
$email = $config['iredmail_admin_email'] ?? '';
$pw = $config['iredmail_admin_password'] ?? '';
if (!$url || !$email || !$pw) {
throw new \RuntimeException('iRedMail-asetukset puuttuvat. Aseta ensin URL, admin-sähköposti ja salasana.');
function getIRedMailClient(string $companyId): IRedMailClient {
$integ = dbGetIntegration($companyId, 'iredmail');
if (!$integ || !$integ['enabled']) throw new \RuntimeException('iRedMail-integraatio ei ole käytössä');
$cfg = $integ['config'] ?? [];
$host = $cfg['db_host'] ?? '';
$dbName = $cfg['db_name'] ?? 'vmail';
$dbUser = $cfg['db_user'] ?? '';
$dbPassword = $cfg['db_password'] ?? '';
$port = intval($cfg['db_port'] ?? 3306);
if (!$host || !$dbUser || !$dbPassword) {
throw new \RuntimeException('iRedMail-tietokanta-asetukset puuttuvat. Aseta host, käyttäjä ja salasana.');
}
return new IRedMailClient($url, $email, $pw);
return new IRedMailClient($host, $dbName, $dbUser, $dbPassword, $port);
}
// ==================== TICKETS HELPER ====================
@@ -5613,6 +5663,7 @@ switch ($action) {
if ($integ['config']) {
$cfg = is_string($integ['config']) ? json_decode($integ['config'], true) : $integ['config'];
if (isset($cfg['token'])) $cfg['token'] = str_repeat('*', 8);
if (isset($cfg['db_password'])) $cfg['db_password'] = str_repeat('*', 8);
$integ['config'] = $cfg;
}
}
@@ -5632,12 +5683,17 @@ switch ($action) {
if ($config === null || (is_array($config) && empty($config))) {
$config = ($old && isset($old['config'])) ? (is_string($old['config']) ? json_decode($old['config'], true) : $old['config']) : [];
} else {
// Jos token on maskattua (********), säilytä vanha
// Jos token/password on maskattua (********), säilytä vanha
if (isset($config['token']) && preg_match('/^\*+$/', $config['token'])) {
if ($old && isset($old['config']['token'])) {
$config['token'] = is_string($old['config']) ? json_decode($old['config'], true)['token'] : $old['config']['token'];
}
}
if (isset($config['db_password']) && preg_match('/^\*+$/', $config['db_password'])) {
if ($old && isset($old['config']['db_password'])) {
$config['db_password'] = is_string($old['config']) ? json_decode($old['config'], true)['db_password'] : $old['config']['db_password'];
}
}
}
dbSaveIntegration($companyId, $type, $enabled, $config);
@@ -5657,6 +5713,10 @@ switch ($action) {
$z = new ZammadClient($cfg['url'], $cfg['token']);
$result = $z->testConnection();
echo json_encode($result);
} elseif ($type === 'iredmail') {
$client = getIRedMailClient($companyId);
$result = $client->testConnection();
echo json_encode($result);
} else {
echo json_encode(['error' => 'Tuntematon tyyppi']);
}
@@ -6040,33 +6100,12 @@ switch ($action) {
// ==================== IREDMAIL HALLINTA ====================
case 'iredmail_config':
requireSuperAdmin();
$config = dbLoadConfig();
echo json_encode([
'url' => $config['iredmail_api_url'] ?? '',
'admin_email' => $config['iredmail_admin_email'] ?? '',
'has_password' => !empty($config['iredmail_admin_password']),
]);
break;
case 'iredmail_config_save':
requireSuperAdmin();
if ($method !== 'POST') break;
$input = json_decode(file_get_contents('php://input'), true);
$config = dbLoadConfig();
if (isset($input['url'])) $config['iredmail_api_url'] = trim($input['url']);
if (isset($input['admin_email'])) $config['iredmail_admin_email'] = trim($input['admin_email']);
if (!empty($input['password'])) $config['iredmail_admin_password'] = $input['password'];
dbSaveConfig($config);
echo json_encode(['ok' => true]);
break;
case 'iredmail_test':
$companyId = requireCompany();
requireSuperAdmin();
if ($method !== 'POST') break;
try {
$client = getIRedMailClient();
$client = getIRedMailClient($companyId);
$result = $client->testConnection();
echo json_encode($result);
} catch (\Throwable $e) {
@@ -6076,11 +6115,11 @@ switch ($action) {
break;
case 'iredmail_domains':
$companyId = requireCompany();
requireSuperAdmin();
try {
$client = getIRedMailClient();
$result = $client->getDomains();
echo json_encode($result['_data'] ?? []);
$client = getIRedMailClient($companyId);
echo json_encode($client->getDomains());
} catch (\Throwable $e) {
http_response_code(500);
echo json_encode(['error' => $e->getMessage()]);
@@ -6088,17 +6127,17 @@ switch ($action) {
break;
case 'iredmail_domain_create':
$companyId = requireCompany();
requireSuperAdmin();
if ($method !== 'POST') break;
$input = json_decode(file_get_contents('php://input'), true);
$domain = trim($input['domain'] ?? '');
if (!$domain) { http_response_code(400); echo json_encode(['error' => 'Domain puuttuu']); break; }
try {
$client = getIRedMailClient();
$client = getIRedMailClient($companyId);
$opts = [];
if (!empty($input['cn'])) $opts['cn'] = $input['cn'];
if (isset($input['quota'])) $opts['quota'] = intval($input['quota']);
$result = $client->createDomain($domain, $opts);
$client->createDomain($domain, $opts);
echo json_encode(['ok' => true]);
} catch (\Throwable $e) {
http_response_code(500);
@@ -6107,13 +6146,14 @@ switch ($action) {
break;
case 'iredmail_domain_delete':
$companyId = requireCompany();
requireSuperAdmin();
if ($method !== 'POST') break;
$input = json_decode(file_get_contents('php://input'), true);
$domain = trim($input['domain'] ?? '');
if (!$domain) { http_response_code(400); echo json_encode(['error' => 'Domain puuttuu']); break; }
try {
$client = getIRedMailClient();
$client = getIRedMailClient($companyId);
$client->deleteDomain($domain);
echo json_encode(['ok' => true]);
} catch (\Throwable $e) {
@@ -6123,13 +6163,13 @@ switch ($action) {
break;
case 'iredmail_users':
$companyId = requireCompany();
requireSuperAdmin();
$domain = trim($_GET['domain'] ?? '');
if (!$domain) { http_response_code(400); echo json_encode(['error' => 'Domain puuttuu']); break; }
try {
$client = getIRedMailClient();
$result = $client->getUsers($domain);
echo json_encode($result['_data'] ?? []);
$client = getIRedMailClient($companyId);
echo json_encode($client->getUsers($domain));
} catch (\Throwable $e) {
http_response_code(500);
echo json_encode(['error' => $e->getMessage()]);
@@ -6137,6 +6177,7 @@ switch ($action) {
break;
case 'iredmail_user_create':
$companyId = requireCompany();
requireSuperAdmin();
if ($method !== 'POST') break;
$input = json_decode(file_get_contents('php://input'), true);
@@ -6144,7 +6185,7 @@ switch ($action) {
$password = $input['password'] ?? '';
if (!$email || !$password) { http_response_code(400); echo json_encode(['error' => 'Sähköposti ja salasana vaaditaan']); break; }
try {
$client = getIRedMailClient();
$client = getIRedMailClient($companyId);
$opts = [];
if (!empty($input['cn'])) $opts['cn'] = $input['cn'];
if (isset($input['mailQuota'])) $opts['mailQuota'] = intval($input['mailQuota']);
@@ -6157,13 +6198,14 @@ switch ($action) {
break;
case 'iredmail_user_update':
$companyId = requireCompany();
requireSuperAdmin();
if ($method !== 'POST') break;
$input = json_decode(file_get_contents('php://input'), true);
$email = trim($input['email'] ?? '');
if (!$email) { http_response_code(400); echo json_encode(['error' => 'Sähköposti puuttuu']); break; }
try {
$client = getIRedMailClient();
$client = getIRedMailClient($companyId);
$opts = [];
if (!empty($input['password'])) $opts['password'] = $input['password'];
if (!empty($input['cn'])) $opts['cn'] = $input['cn'];
@@ -6178,13 +6220,14 @@ switch ($action) {
break;
case 'iredmail_user_delete':
$companyId = requireCompany();
requireSuperAdmin();
if ($method !== 'POST') break;
$input = json_decode(file_get_contents('php://input'), true);
$email = trim($input['email'] ?? '');
if (!$email) { http_response_code(400); echo json_encode(['error' => 'Sähköposti puuttuu']); break; }
try {
$client = getIRedMailClient();
$client = getIRedMailClient($companyId);
$client->deleteUser($email);
echo json_encode(['ok' => true]);
} catch (\Throwable $e) {
@@ -6194,13 +6237,13 @@ switch ($action) {
break;
case 'iredmail_aliases':
$companyId = requireCompany();
requireSuperAdmin();
$domain = trim($_GET['domain'] ?? '');
if (!$domain) { http_response_code(400); echo json_encode(['error' => 'Domain puuttuu']); break; }
try {
$client = getIRedMailClient();
$result = $client->getAliases($domain);
echo json_encode($result['_data'] ?? []);
$client = getIRedMailClient($companyId);
echo json_encode($client->getAliases($domain));
} catch (\Throwable $e) {
http_response_code(500);
echo json_encode(['error' => $e->getMessage()]);
@@ -6208,17 +6251,16 @@ switch ($action) {
break;
case 'iredmail_alias_create':
$companyId = requireCompany();
requireSuperAdmin();
if ($method !== 'POST') break;
$input = json_decode(file_get_contents('php://input'), true);
$alias = trim($input['alias'] ?? '');
$members = trim($input['members'] ?? '');
if (!$alias) { http_response_code(400); echo json_encode(['error' => 'Alias puuttuu']); break; }
try {
$client = getIRedMailClient();
$opts = [];
if (!empty($input['cn'])) $opts['cn'] = $input['cn'];
if (!empty($input['members'])) $opts['accessPolicy'] = 'membersonly';
$client->createAlias($alias, $opts);
$client = getIRedMailClient($companyId);
$client->createAlias($alias, $members);
echo json_encode(['ok' => true]);
} catch (\Throwable $e) {
http_response_code(500);
@@ -6227,13 +6269,14 @@ switch ($action) {
break;
case 'iredmail_alias_delete':
$companyId = requireCompany();
requireSuperAdmin();
if ($method !== 'POST') break;
$input = json_decode(file_get_contents('php://input'), true);
$alias = trim($input['alias'] ?? '');
if (!$alias) { http_response_code(400); echo json_encode(['error' => 'Alias puuttuu']); break; }
try {
$client = getIRedMailClient();
$client = getIRedMailClient($companyId);
$client->deleteAlias($alias);
echo json_encode(['ok' => true]);
} catch (\Throwable $e) {