diff --git a/api/calendar_manager.py b/api/calendar_manager.py index c21deac..b0e1659 100644 --- a/api/calendar_manager.py +++ b/api/calendar_manager.py @@ -10,6 +10,7 @@ import subprocess import logging from datetime import datetime from typing import Dict, List, Optional, Any +import bcrypt from base_service_manager import BaseServiceManager logger = logging.getLogger(__name__) @@ -280,12 +281,51 @@ class CalendarManager(BaseServiceManager): user_dir = os.path.join(self.calendar_data_dir, 'users', username) self.safe_makedirs(user_dir) + # Write bcrypt entry to Radicale htpasswd (non-fatal if service not installed) + self._write_radicale_htpasswd(username, password) + logger.info(f"Created calendar user: {username}") return True except Exception as e: logger.error(f"Failed to create calendar user {username}: {e}") return False + def _radicale_htpasswd_path(self) -> str: + return os.path.join(self.data_dir, 'services', 'calendar', 'config', 'users') + + def _write_radicale_htpasswd(self, username: str, password: str) -> None: + htpasswd = self._radicale_htpasswd_path() + config_dir = os.path.dirname(htpasswd) + if not os.path.isdir(config_dir): + return + try: + raw = bcrypt.hashpw(password.encode('utf-8'), bcrypt.gensalt()).decode('utf-8') + if raw.startswith('$2b$'): + raw = '$2y$' + raw[4:] + lines = [] + if os.path.exists(htpasswd): + with open(htpasswd) as f: + lines = f.readlines() + lines = [l for l in lines if not l.startswith(f'{username}:')] + lines.append(f'{username}:{raw}\n') + with open(htpasswd, 'w') as f: + f.writelines(lines) + except Exception as e: + logger.warning('Failed to write Radicale htpasswd for %s: %s', username, e) + + def _remove_radicale_htpasswd(self, username: str) -> None: + htpasswd = self._radicale_htpasswd_path() + if not os.path.exists(htpasswd): + return + try: + with open(htpasswd) as f: + lines = f.readlines() + lines = [l for l in lines if not l.startswith(f'{username}:')] + with open(htpasswd, 'w') as f: + f.writelines(lines) + except Exception as e: + logger.warning('Failed to remove Radicale htpasswd for %s: %s', username, e) + def delete_calendar_user(self, username: str) -> bool: """Delete a calendar user""" try: @@ -306,6 +346,7 @@ class CalendarManager(BaseServiceManager): import shutil shutil.rmtree(user_dir) + self._remove_radicale_htpasswd(username) logger.info(f"Deleted calendar user: {username}") return True diff --git a/api/email_manager.py b/api/email_manager.py index 9f63cb9..7327217 100644 --- a/api/email_manager.py +++ b/api/email_manager.py @@ -340,12 +340,39 @@ class EmailManager(BaseServiceManager): mailbox_dir = os.path.join(self.email_data_dir, 'mailboxes', f'{username}@{domain}') self.safe_makedirs(mailbox_dir) + # Provision account in docker-mailserver (non-fatal if container not running) + self._dms_add_account(username, domain, password) + logger.info(f"Created email user: {username}@{domain}") return True except Exception as e: logger.error(f"Failed to create email user {username}@{domain}: {e}") return False + def _dms_add_account(self, username: str, domain: str, password: str) -> None: + try: + r = subprocess.run( + ['docker', 'exec', 'cell-mail', 'setup', 'email', 'add', + f'{username}@{domain}', password], + capture_output=True, text=True, timeout=30, check=False, + ) + if r.returncode != 0: + logger.warning('dms add account %s@%s: %s', username, domain, r.stderr.strip()) + except Exception as e: + logger.warning('dms add account %s@%s failed (non-fatal): %s', username, domain, e) + + def _dms_del_account(self, username: str, domain: str) -> None: + try: + r = subprocess.run( + ['docker', 'exec', 'cell-mail', 'setup', 'email', 'del', + f'{username}@{domain}'], + capture_output=True, text=True, timeout=30, check=False, + ) + if r.returncode != 0: + logger.warning('dms del account %s@%s: %s', username, domain, r.stderr.strip()) + except Exception as e: + logger.warning('dms del account %s@%s failed (non-fatal): %s', username, domain, e) + def delete_email_user(self, username: str, domain: str) -> bool: """Delete an email user""" try: @@ -366,6 +393,7 @@ class EmailManager(BaseServiceManager): import shutil shutil.rmtree(mailbox_dir) + self._dms_del_account(username, domain) logger.info(f"Deleted email user: {username}@{domain}") return True diff --git a/api/service_composer.py b/api/service_composer.py index 1e0fb3f..a982d79 100644 --- a/api/service_composer.py +++ b/api/service_composer.py @@ -253,9 +253,21 @@ class ServiceComposer: def install(self, service_id: str, manifest: Dict, template_content: str) -> Dict: - """Write compose file and start containers.""" + """Write compose file, pull image, then start containers. + + pull is run first so the up step doesn't time out on slow connections. + A single retry handles transient registry hiccups on first install. + """ self.write_compose(service_id, manifest, template_content) - return self.up(service_id) + pull = self._store_cmd(service_id, 'pull', timeout=600) + if not pull.get('ok'): + logger.warning('service_composer: image pull for %s failed, proceeding anyway: %s', + service_id, pull.get('stderr', '')[:200]) + result = self.up(service_id) + if not result.get('ok'): + logger.info('service_composer: retrying up for %s after initial failure', service_id) + result = self.up(service_id) + return result def remove(self, service_id: str, purge_data: bool = False) -> Dict: """Stop containers, optionally delete compose file, secrets, and service data dir."""