initial commit

This commit is contained in:
Jonas Heinrich 2025-08-08 14:59:08 +02:00
parent 52145d19fe
commit a1ea6e4609
6 changed files with 316 additions and 160 deletions

25
config.yml.example Normal file
View file

@ -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

View file

@ -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}")

8
flake.lock generated
View file

@ -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"
}
},

View file

@ -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);
};
}

269
mail-quota-warning.py Normal file
View file

@ -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()

View file

@ -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 = [
""