diff --git a/config.yml.example b/config.yml.example new file mode 100644 index 0000000..dfc6526 --- /dev/null +++ b/config.yml.example @@ -0,0 +1,25 @@ +check_interval_days: 7 # Minimum days between warnings for same account +quota_warning_threshold_percent: 80 + +accounts: + - name: Sales + imap_server: mail.example.com + imap_port: 993 + username: sales@example.com + password: secret + + - name: Support + imap_server: mail.example.com + imap_port: 993 + username: support@example.com + password: secret + +mail: + smtp_server: mail.example.com + smtp_port: 587 + smtp_username: monitoring@example.com + smtp_password: secret + from_address: monitoring@example.com + recipients: + - admin1@example.com + - admin2@example.com diff --git a/eintopf-radar-sync.py b/eintopf-radar-sync.py deleted file mode 100644 index b3dbfdc..0000000 --- a/eintopf-radar-sync.py +++ /dev/null @@ -1,138 +0,0 @@ -#!/bin/env python - -import requests -import json -from bs4 import BeautifulSoup -import os -import sys - -# Read environment variables (fail if missing) -EINTOPF_URL = os.environ["EINTOPF_URL"] -RADAR_GROUP_ID = os.environ["RADAR_GROUP_ID"] -EINTOPF_AUTHORIZATION_TOKEN = os.environ["EINTOPF_AUTHORIZATION_TOKEN"] - -def strip_html_tags(text): - soup = BeautifulSoup(text, "html.parser") - return soup.get_text() - -def query_categories(): - response = requests.get(EINTOPF_URL + "/api/v1/categories/?filters=%7B%7D", headers={ - "Authorization": EINTOPF_AUTHORIZATION_TOKEN, - "Content-Type": "application/json" - }) - - if response.status_code == 200: - data = response.json() - return data - else: - return False - -def eintopf_create_category(name): - payload = { - "description": name, - "headline": name, - "name": name - } - response = requests.post(EINTOPF_URL + "/api/v1/categories/", json=payload, headers={ - "Authorization": EINTOPF_AUTHORIZATION_TOKEN, - "Content-Type": "application/json" - }) - - if response.status_code == 200: - data = response.json() - return data['id'] - else: - return False - -def ensure_and_get_categoryid(category): - available_categories = query_categories() - - for entry in available_categories: - if entry["name"].lower() == category: - # Return the matching category ID - return entry["id"] - - # If no matching category is found, create a new one - category_id = eintopf_create_category(category) - return category_id - -def eintopf_post_event(event: dict): - payload = { - "address": "Karlsruhe", - "category": event['category_id'], - "description": strip_html_tags(event['description']), - "image": "", - "involved": [ - { - "description": "Anonymous", - "name": "Anonymous" - } - ], - "lat": 0, - "lng": 0, - "location": event['location'], - "name": event['title'], - "organizers": [ event['organizer'] ], - "ownedBy": [ event['organizer'] ], - "published": True, - "start": event['time_start'], - "end": event['time_end'], - "tags": ["karlsruhe"], - "topic": event['topic_id'], - } - response = requests.post(EINTOPF_URL + "/api/v1/events/", json=payload, headers={ - "Authorization": EINTOPF_AUTHORIZATION_TOKEN, - "Content-Type": "application/json" - }) - - if response.status_code == 200: - return True - else: - return False - -print("Beginning scraping Radar api ...") - -response = requests.get("https://radar.squat.net/api/1.2/search/events.json", params={ - "fields": "title,offline,date_time,body,category,uuid,og_group_ref", - "facets[group][]=": RADAR_GROUP_ID -}) - -if response.status_code == 200: - data = response.json() - events = data["result"] - - radar_events = [] - - for event in events: - - event = events[event] - categories = [cat["name"] for cat in event.get("category", [])] - category_id = ensure_and_get_categoryid(categories[0]) - new_event = { - 'title': event["title"], - 'time_start': event["date_time"][0]["time_start"], - 'time_end': event["date_time"][0]["time_end"], - 'location': event["offline"][0]['title'], - 'description': event["body"]['value'], - 'category_id': category_id, - 'topic_id': "003387f0-9f28-44e4-ab41-808007bc6586", - 'uuid': event["uuid"], - 'organizer': event["og_group_ref"][0]["title"] - } - - if eintopf_post_event(new_event): - print("Event successfully added:") - print(f"Title: {new_event['title']}") - print(f"Time Start: {new_event['time_start']}") - print(f"Location: {new_event['location']}") - print(f"Category: {categories[0]} ({new_event['category_id']})") - print(f"Topic: Sonstiges ({new_event['topic_id']})") - print(f"UUID: {new_event['uuid']}") - print(f"Organizer: {new_event['organizer']}") - print('-' * 40) - else: - print("Submitting event failed") - sys.exit(1) -else: - print(f"Failed to retrieve data. Status code: {response.status_code}") - diff --git a/flake.lock b/flake.lock index 30c2eac..752db5b 100644 --- a/flake.lock +++ b/flake.lock @@ -2,16 +2,16 @@ "nodes": { "nixpkgs": { "locked": { - "lastModified": 1741600792, - "narHash": "sha256-yfDy6chHcM7pXpMF4wycuuV+ILSTG486Z/vLx/Bdi6Y=", + "lastModified": 1754563854, + "narHash": "sha256-YzNTExe3kMY9lYs23mZR7jsVHe5TWnpwNrsPOpFs/b8=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "ebe2788eafd539477f83775ef93c3c7e244421d3", + "rev": "e728d7ae4bb6394bbd19eec52b7358526a44c414", "type": "github" }, "original": { "id": "nixpkgs", - "ref": "nixos-24.11", + "ref": "nixos-25.05", "type": "indirect" } }, diff --git a/flake.nix b/flake.nix index 6ca073c..9379539 100644 --- a/flake.nix +++ b/flake.nix @@ -1,7 +1,7 @@ { - description = "eintopf-radar-sync package and service"; + description = "mail-quota-warning package and service"; - inputs.nixpkgs.url = "nixpkgs/nixos-24.11"; + inputs.nixpkgs.url = "nixpkgs/nixos-25.05"; outputs = { self, nixpkgs }: let @@ -16,8 +16,8 @@ ); in { overlay = final: prev: { - eintopf-radar-sync = with final; python3Packages.buildPythonApplication { - pname = "eintopf-radar-sync"; + mail-quota-warning = with final; python3Packages.buildPythonApplication { + pname = "mail-quota-warning"; version = "0.0.1"; format = "other"; @@ -25,23 +25,23 @@ dependencies = with python3Packages; [ python - requests - beautifulsoup4 + pyyaml + imaplib2 ]; installPhase = '' - install -Dm755 ${./eintopf-radar-sync.py} $out/bin/eintopf-radar-sync + install -Dm755 ${./mail-quota-warning.py} $out/bin/mail-quota-warning ''; - meta.mainProgram = "eintopf-radar-sync"; + meta.mainProgram = "mail-quota-warning"; }; }; packages = forAllSystems (system: { - inherit (nixpkgsFor.${system}) eintopf-radar-sync; + inherit (nixpkgsFor.${system}) mail-quota-warning; }); - defaultPackage = forAllSystems (system: self.packages.${system}.eintopf-radar-sync); + defaultPackage = forAllSystems (system: self.packages.${system}.mail-quota-warning); devShells = forAllSystems (system: let pkgs = import nixpkgs { inherit system; overlays = [ self.overlay ]; }; @@ -53,7 +53,7 @@ ]; }); - # eintopf-radar-sync service module + # mail-quota-warning service module nixosModule = (import ./module.nix); }; } diff --git a/mail-quota-warning.py b/mail-quota-warning.py new file mode 100644 index 0000000..8843d74 --- /dev/null +++ b/mail-quota-warning.py @@ -0,0 +1,269 @@ +#!/usr/bin/env python3 +import imaplib2 +import smtplib +import yaml +import json +import os +import threading +from email.mime.text import MIMEText +from datetime import datetime, timedelta + +CONFIG_FILE = "config.yml" +STATE_FILE = "quota_state.json" + +# TODO: +# - make working dir configurable for state file +# - optional read config.yml from command line argument +# - note in warning mail that it exceeded XX% quota threashold +# - fix list summary mark all accounts which are critical + +def load_config(): + with open(CONFIG_FILE, "r") as f: + return yaml.safe_load(f) + +def load_state(): + if os.path.exists(STATE_FILE): + with open(STATE_FILE, "r") as f: + return json.load(f) + return {} + +def save_state(state): + with open(STATE_FILE, "w") as f: + json.dump(state, f, indent=2) + +def log(msg): + print(f"[{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}] {msg}") + +def format_bytes(kb): + """Convert KB to human readable format""" + if kb >= 1024 * 1024: # GB + return f"{kb / (1024 * 1024):.1f} GB" + elif kb >= 1024: # MB + return f"{kb / 1024:.1f} MB" + else: # KB + return f"{kb} KB" + +def check_quota(account): + """ + Uses IMAP QUOTA command (RFC 2087) to get mailbox usage with imaplib2. + Tries multiple approaches to get quota information. + Returns dict with quota info or None if not supported. + """ + try: + log(f"Checking quota for account: {account['name']} ({account['username']})") + + # Create imaplib2 connection + mail = imaplib2.IMAP4_SSL(account['imap_server'], account['imap_port']) + + # Login + typ, data = mail.login(account['username'], account['password']) + if typ != 'OK': + log(f"Login failed for {account['name']}: {data}") + return None + + # Try different quota roots (quietly) + quota_roots = ["INBOX", account['username'], f"user/{account['username']}", f"user.{account['username']}"] + + for quota_root in quota_roots: + try: + typ, data = mail.getquota(quota_root) + + if typ == "OK" and data and data[0]: + # Parse quota response + quota_info = data[0].decode() if isinstance(data[0], bytes) else str(data[0]) + + # Handle different response formats + # Format 1: (STORAGE used limit) or (MESSAGE used limit) + if quota_info.startswith('('): + parts = quota_info.replace("(", "").replace(")", "").split() + if len(parts) >= 3: + resource_type = parts[0] # STORAGE or MESSAGE + used = int(parts[1]) + limit = int(parts[2]) + + if limit > 0 and resource_type == "STORAGE": + percent_used = (used / limit) * 100 + log(f"Quota usage: {percent_used:.1f}% ({format_bytes(used)} of {format_bytes(limit)})") + mail.logout() + return { + 'percent_used': percent_used, + 'used_kb': used, + 'limit_kb': limit + } + + # Format 2: Multiple resources in one response + elif "STORAGE" in quota_info: + import re + storage_match = re.search(r'STORAGE\s+(\d+)\s+(\d+)', quota_info) + if storage_match: + used = int(storage_match.group(1)) + limit = int(storage_match.group(2)) + if limit > 0: + percent_used = (used / limit) * 100 + log(f"Quota usage: {percent_used:.1f}% ({format_bytes(used)} of {format_bytes(limit)})") + mail.logout() + return { + 'percent_used': percent_used, + 'used_kb': used, + 'limit_kb': limit + } + + except imaplib2.IMAP4.error: + # Silently try next quota root + continue + + # Try GETQUOTAROOT command as alternative + try: + typ, data = mail.getquotaroot("INBOX") + if typ == "OK" and data: + # Parse quotaroot response which might give us quota info + for item in data: + item_str = item.decode() if isinstance(item, bytes) else str(item) + if "STORAGE" in item_str: + import re + storage_match = re.search(r'STORAGE\s+(\d+)\s+(\d+)', item_str) + if storage_match: + used = int(storage_match.group(1)) + limit = int(storage_match.group(2)) + if limit > 0: + percent_used = (used / limit) * 100 + log(f"Quota usage: {percent_used:.1f}% ({format_bytes(used)} of {format_bytes(limit)})") + mail.logout() + return { + 'percent_used': percent_used, + 'used_kb': used, + 'limit_kb': limit + } + except imaplib2.IMAP4.error: + pass + + mail.logout() + log(f"No quota data available for {account['name']}") + return None + + except imaplib2.IMAP4.error as e: + log(f"IMAP error checking quota for {account['name']}: {e}") + except Exception as e: + log(f"Error checking quota for {account['name']}: {e}") + + return None + +def should_send_warning(state, account_name, interval_days): + last_sent_str = state.get(account_name) + if not last_sent_str: + return True + + last_sent = datetime.fromisoformat(last_sent_str) + return datetime.now() - last_sent >= timedelta(days=interval_days) + +def send_warning(config, triggered_accounts, all_quotas): + mail_cfg = config["mail"] + + # Create subject based on number of accounts + if len(triggered_accounts) == 1: + account_name, quota_info = list(triggered_accounts.items())[0] + subject = f"[Quota Warning] {account_name} mailbox usage at {quota_info['percent_used']:.1f}%" + else: + subject = f"[Quota Warning] {len(triggered_accounts)} mailbox(es) over threshold" + + # Build email body + body_lines = [] + + if len(triggered_accounts) == 1: + account_name, quota_info = list(triggered_accounts.items())[0] + body_lines.append(f"The mailbox for account '{account_name}' has reached {quota_info['percent_used']:.1f}% of its quota.") + body_lines.append(f"Usage: {format_bytes(quota_info['used_kb'])} of {format_bytes(quota_info['limit_kb'])}") + else: + body_lines.append("The following mailboxes have exceeded the quota threshold:") + body_lines.append("") + for account_name, quota_info in triggered_accounts.items(): + body_lines.append(f"• {account_name}: {quota_info['percent_used']:.1f}% ({format_bytes(quota_info['used_kb'])} of {format_bytes(quota_info['limit_kb'])})") + + body_lines.append("") + body_lines.append("--- All Account Summary ---") + + # Sort accounts by usage percentage (descending) + sorted_quotas = sorted(all_quotas.items(), key=lambda x: x[1]['percent_used'] if x[1] else 0, reverse=True) + + for account_name, quota_info in sorted_quotas: + if quota_info: + status = "⚠️ " if account_name in triggered_accounts else "✓ " + body_lines.append(f"{status}{account_name}: {quota_info['percent_used']:.1f}% ({format_bytes(quota_info['used_kb'])} of {format_bytes(quota_info['limit_kb'])})") + else: + body_lines.append(f"? {account_name}: Quota info unavailable") + + body_lines.append("") + body_lines.append("Please take action to free up space for accounts over the threshold.") + + body = "\n".join(body_lines) + + msg = MIMEText(body) + msg["Subject"] = subject + msg["From"] = mail_cfg["from_address"] + msg["To"] = ", ".join(mail_cfg["recipients"]) + + try: + server = smtplib.SMTP(mail_cfg["smtp_server"], mail_cfg["smtp_port"]) + server.starttls() + server.login(mail_cfg["smtp_username"], mail_cfg["smtp_password"]) + server.sendmail(mail_cfg["from_address"], mail_cfg["recipients"], msg.as_string()) + server.quit() + account_names = ", ".join(triggered_accounts.keys()) + log(f"Warning email sent for: {account_names}") + except Exception as e: + log(f"Error sending warning email: {e}") + +def check_account_quota(account, config, state, threshold, interval_days): + """ + Check quota for a single account (can be run in thread) + """ + quota_info = check_quota(account) + if quota_info is None: + log(f"Quota info not available for {account['name']}") + return None, None + + percent_used = quota_info['percent_used'] + + if percent_used >= threshold: + if should_send_warning(state, account["name"], interval_days): + return account["name"], quota_info + else: + log(f"Warning already sent recently for {account['name']}, skipping.") + else: + log(f"Quota usage for {account['name']} is below threshold ({percent_used:.1f}%).") + + return None, quota_info + +def main(): + config = load_config() + state = load_state() + interval_days = config.get("check_interval_days", 7) + threshold = config.get("quota_warning_threshold_percent", 80) + + # For thread-safe state updates + state_lock = threading.Lock() + + # Track all accounts and those that need warnings + triggered_accounts = {} # account_name: quota_info + all_quotas = {} # account_name: quota_info (or None) + + for account in config["accounts"]: + warning_result, quota_info = check_account_quota(account, config, state, threshold, interval_days) + + # Store quota info for summary + all_quotas[account["name"]] = quota_info + + # Track accounts that need warnings + if warning_result: + triggered_accounts[account["name"]] = quota_info + with state_lock: + state[account["name"]] = datetime.now().isoformat() + + # Send consolidated warning email if any accounts triggered + if triggered_accounts: + send_warning(config, triggered_accounts, all_quotas) + + save_state(state) + +if __name__ == "__main__": + main() diff --git a/module.nix b/module.nix index 1ce9a76..375b462 100644 --- a/module.nix +++ b/module.nix @@ -1,19 +1,19 @@ {config, lib, pkgs, ...}: let - cfg = config.services.eintopf-radar-sync; + cfg = config.services.mail-quota-warning; in { options = { - services.eintopf-radar-sync = { + services.mail-quota-warning = { enable = lib.mkOption { type = lib.types.bool; default = false; description = '' - Enable eintopf-radar-sync daemon. + Enable mail-quota-warning daemon. ''; }; @@ -74,8 +74,8 @@ in config = lib.mkIf cfg.enable { - systemd.services."eintopf-radar-sync" = { - description = "eintopf-radar-sync script"; + systemd.services."mail-quota-warning" = { + description = "mail-quota-warning script"; after = [ "network.target" ]; wants = [ "network-online.target" ]; environment = { @@ -83,7 +83,7 @@ in } // cfg.settings; serviceConfig = { Type = "simple"; - ExecStart = lib.getExe pkgs.eintopf-radar-sync; + ExecStart = lib.getExe pkgs.mail-quota-warning; EnvironmentFile = [ cfg.secrets ]; # hardening @@ -118,7 +118,7 @@ in }; }; - systemd.timers.eintopf-radar-sync = { + systemd.timers.mail-quota-warning = { timerConfig = { OnCalendar = [ ""