initial commit
This commit is contained in:
parent
52145d19fe
commit
a1ea6e4609
6 changed files with 316 additions and 160 deletions
25
config.yml.example
Normal file
25
config.yml.example
Normal 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
|
||||
|
|
@ -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
8
flake.lock
generated
|
|
@ -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"
|
||||
}
|
||||
},
|
||||
|
|
|
|||
22
flake.nix
22
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);
|
||||
};
|
||||
}
|
||||
|
|
|
|||
269
mail-quota-warning.py
Normal file
269
mail-quota-warning.py
Normal 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()
|
||||
14
module.nix
14
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 = [
|
||||
""
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue