Nur mails mit ics anängen werden als gelesen markiert
This commit is contained in:
@@ -1,15 +1,28 @@
|
|||||||
# ICS-Importer für mailbox.org unter Linux
|
# ICS-Importer für mailbox.org unter Linux
|
||||||
|
|
||||||
Dieses Projekt durchsucht ein mailbox.org-Postfach per IMAP nach E-Mails mit `.ics`-Anhängen und importiert enthaltene Termine automatisch per CalDAV in einen mailbox.org-Kalender.[cite:55][cite:45]
|
Dieses Projekt durchsucht ein mailbox.org-Postfach per IMAP nach E-Mails mit `.ics`-Anhängen und importiert enthaltene Termine automatisch per CalDAV in einen mailbox.org-Kalender.[cite:45][cite:55]
|
||||||
|
|
||||||
## Überblick
|
## Überblick
|
||||||
|
|
||||||
Der technische Ablauf ist bewusst einfach gehalten:
|
Der Importer ist für ein einfaches, robustes Linux-Setup gedacht:
|
||||||
|
|
||||||
1. Das Python-Skript verbindet sich per IMAP mit mailbox.org und durchsucht einen Ordner, standardmäßig `INBOX`.[cite:56][cite:55]
|
1. Das Skript verbindet sich per IMAP mit mailbox.org und durchsucht einen Ordner, standardmäßig `INBOX`.[cite:55][cite:338]
|
||||||
2. Es erkennt `.ics`-Anhänge, berechnet einen Hash als Duplikat-Schutz und verarbeitet nur neue Anhänge.
|
2. Nachrichten werden mit `BODY.PEEK[]` gelesen, damit sie beim Abruf nicht automatisch als gelesen markiert werden.[cite:338][cite:350]
|
||||||
3. Die Termine werden per CalDAV in den gewünschten mailbox.org-Kalender geschrieben, dessen URL in mailbox.org über die Kalendereigenschaften ermittelt wird.[cite:45][cite:51]
|
3. `.ics`-Anhänge werden erkannt, per Hash gegen Doppelverarbeitung abgesichert und anschließend per CalDAV importiert.[cite:45][cite:162]
|
||||||
4. Die Ausführung erfolgt manuell oder stündlich per Cron.
|
4. Nur Mails mit mindestens einem **erfolgreich importierten** ICS-Anhang werden optional als gelesen markiert; fehlgeschlagene oder irrelevante Mails bleiben ungelesen.[cite:340][cite:357]
|
||||||
|
|
||||||
|
## Projektstruktur
|
||||||
|
|
||||||
|
```text
|
||||||
|
ics-importer/
|
||||||
|
├── ics_mail_importer_env_v2.py
|
||||||
|
├── .env.example
|
||||||
|
├── .env
|
||||||
|
├── imported_uids.txt
|
||||||
|
├── ics_importer.log
|
||||||
|
├── cron.log
|
||||||
|
└── README.md
|
||||||
|
```
|
||||||
|
|
||||||
## Voraussetzungen
|
## Voraussetzungen
|
||||||
|
|
||||||
@@ -19,37 +32,22 @@ Benötigt werden:
|
|||||||
- Python 3
|
- Python 3
|
||||||
- `pipenv`
|
- `pipenv`
|
||||||
- Ein mailbox.org-Konto
|
- Ein mailbox.org-Konto
|
||||||
- Zugriff auf IMAP und CalDAV
|
- IMAP- und CalDAV-Zugang
|
||||||
- Bei aktiver Zwei-Faktor-Authentifizierung ein App- bzw. Applikationspasswort für externe Anwendungen.[cite:63][cite:45]
|
- Bei aktiver Zwei-Faktor-Authentifizierung ein App- oder Applikationspasswort für externe Anwendungen.[cite:45][cite:55]
|
||||||
|
|
||||||
## Projektstruktur
|
|
||||||
|
|
||||||
```text
|
|
||||||
ics-importer/
|
|
||||||
├── ics_mail_importer_env.py
|
|
||||||
├── .env.example
|
|
||||||
├── .env
|
|
||||||
├── imported_uids.txt
|
|
||||||
├── ics_importer.log
|
|
||||||
├── cron.log
|
|
||||||
└── README.md
|
|
||||||
```
|
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
Projektverzeichnis anlegen und Abhängigkeiten installieren:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
mkdir -p ~/ics-importer
|
mkdir -p ~/ics-importer
|
||||||
cd ~/ics-importer
|
cd ~/ics-importer
|
||||||
pipenv install python-dotenv caldav icalendar
|
pipenv install python-dotenv caldav icalendar
|
||||||
```
|
```
|
||||||
|
|
||||||
`python-dotenv` liest Schlüssel-Wert-Paare aus einer `.env`-Datei und stellt sie als Umgebungsvariablen bereit.[cite:162][cite:164]
|
`python-dotenv` liest Schlüssel-Wert-Paare aus einer `.env`-Datei und stellt sie dem Skript als Umgebungsvariablen bereit.[cite:162][cite:164]
|
||||||
|
|
||||||
## Konfiguration mit `.env`
|
## Konfiguration mit `.env`
|
||||||
|
|
||||||
Eine `.env.example` kann als Vorlage verwendet werden:
|
Beispiel für `.env.example`:
|
||||||
|
|
||||||
```dotenv
|
```dotenv
|
||||||
IMAP_HOST=imap.mailbox.org
|
IMAP_HOST=imap.mailbox.org
|
||||||
@@ -58,18 +56,18 @@ IMAP_USERNAME=ihr-name@mailbox.org
|
|||||||
IMAP_PASSWORD=IHR_PASSWORT_ODER_APP_PASSWORT
|
IMAP_PASSWORD=IHR_PASSWORT_ODER_APP_PASSWORT
|
||||||
IMAP_FOLDER=INBOX
|
IMAP_FOLDER=INBOX
|
||||||
IMAP_UNSEEN_ONLY=true
|
IMAP_UNSEEN_ONLY=true
|
||||||
IMAP_MARK_AS_READ=false
|
IMAP_MARK_AS_READ=true
|
||||||
|
|
||||||
CALDAV_URL=https://dav.mailbox.org/caldav/IHR_KALENDER_ID
|
CALDAV_URL=https://dav.mailbox.org/caldav/IHR_KALENDER_ID
|
||||||
CALDAV_USERNAME=ihr-name@mailbox.org
|
CALDAV_USERNAME=ihr-name@mailbox.org
|
||||||
CALDAV_PASSWORD=IHR_PASSWORT_ODER_APP_PASSWORT
|
CALDAV_PASSWORD=IHR_PASSWORT_ODER_APP_PASSWORT
|
||||||
```
|
```
|
||||||
|
|
||||||
Die IMAP-Standardwerte für mailbox.org sind `imap.mailbox.org`, Port `993`, SSL/TLS und die vollständige E-Mail-Adresse als Benutzername.[cite:56][cite:55]
|
Die IMAP-Standardwerte für mailbox.org sind `imap.mailbox.org` und Port `993`; die vollständige mailbox.org-E-Mail-Adresse wird als Benutzername verwendet.[cite:55]
|
||||||
|
|
||||||
Die vollständige CalDAV-URL des Zielkalenders wird in mailbox.org im Kalender über **Eigenschaften** angezeigt; für Thunderbird und andere Clients wird `https://dav.mailbox.org/caldav/XXX` verwendet, wobei `XXX` die individuelle Kalender-ID ist.[cite:45][cite:51]
|
Die vollständige CalDAV-URL des Zielkalenders wird in mailbox.org im Kalender über **Eigenschaften** angezeigt; mailbox.org verwendet dafür Adressen im Format `https://dav.mailbox.org/caldav/XXX`.[cite:45][cite:355]
|
||||||
|
|
||||||
`.env` lokal anlegen und schützen:
|
Datei anlegen und schützen:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cp .env.example .env
|
cp .env.example .env
|
||||||
@@ -80,65 +78,81 @@ nano .env
|
|||||||
## Bedeutung der Variablen
|
## Bedeutung der Variablen
|
||||||
|
|
||||||
| Variable | Bedeutung |
|
| Variable | Bedeutung |
|
||||||
| --- | --- |
|
|---|---|
|
||||||
| `IMAP_HOST` | mailbox.org IMAP-Server, normalerweise `imap.mailbox.org`.[cite:56] |
|
| `IMAP_HOST` | mailbox.org IMAP-Server, normalerweise `imap.mailbox.org`.[cite:55] |
|
||||||
| `IMAP_PORT` | IMAP-SSL-Port, normalerweise `993`.[cite:56] |
|
| `IMAP_PORT` | IMAP über SSL/TLS, normalerweise `993`.[cite:55] |
|
||||||
| `IMAP_USERNAME` | mailbox.org-E-Mail-Adresse.[cite:55] |
|
| `IMAP_USERNAME` | mailbox.org-E-Mail-Adresse.[cite:55] |
|
||||||
| `IMAP_PASSWORD` | Passwort oder E-Mail-App-Passwort bei aktiver 2FA.[cite:63] |
|
| `IMAP_PASSWORD` | Passwort oder App-Passwort. |
|
||||||
| `IMAP_FOLDER` | Zu durchsuchender Ordner, meist `INBOX`. |
|
| `IMAP_FOLDER` | Zu prüfender Ordner, meist `INBOX`. |
|
||||||
| `IMAP_UNSEEN_ONLY` | Wenn `true`, werden nur ungelesene Mails geprüft. |
|
| `IMAP_UNSEEN_ONLY` | Wenn `true`, werden nur ungelesene Nachrichten gesucht. |
|
||||||
| `IMAP_MARK_AS_READ` | Wenn `true`, werden verarbeitete Mails als gelesen markiert. |
|
| `IMAP_MARK_AS_READ` | Wenn `true`, werden nur erfolgreich verarbeitete ICS-Mails als gelesen markiert. |
|
||||||
| `CALDAV_URL` | Vollständige CalDAV-URL des Zielkalenders.[cite:45] |
|
| `CALDAV_URL` | Vollständige CalDAV-URL des Zielkalenders.[cite:45] |
|
||||||
| `CALDAV_USERNAME` | mailbox.org-E-Mail-Adresse als CalDAV-Benutzername.[cite:45] |
|
| `CALDAV_USERNAME` | mailbox.org-E-Mail-Adresse als CalDAV-Benutzername.[cite:45] |
|
||||||
| `CALDAV_PASSWORD` | Passwort oder Applikationspasswort bei aktiver 2FA.[cite:63][cite:45] |
|
| `CALDAV_PASSWORD` | Passwort oder Applikationspasswort. |
|
||||||
|
|
||||||
|
## Verhalten beim Markieren als gelesen
|
||||||
|
|
||||||
|
Die aktuelle Skriptversion ist bewusst so gebaut, dass **nicht alle geprüften Mails** als gelesen markiert werden. Stattdessen gilt:
|
||||||
|
|
||||||
|
- Mails ohne ICS-Anhang bleiben unverändert.
|
||||||
|
- Mails mit ICS-Anhang bleiben ungelesen, wenn kein Termin erfolgreich importiert wurde.
|
||||||
|
- Nur Mails mit mindestens einem erfolgreich importierten ICS-Inhalt werden per `UID STORE` mit `\\Seen` markiert, sofern `IMAP_MARK_AS_READ=true` gesetzt ist.[cite:353][cite:357][cite:340]
|
||||||
|
|
||||||
|
Das Abrufen über `BODY.PEEK[]` verhindert, dass der Fetch selbst schon den `Seen`-Status setzt.[cite:350][cite:338]
|
||||||
|
|
||||||
## Python-Skript
|
## Python-Skript
|
||||||
|
|
||||||
Das Skript `ics_mail_importer_env.py` nutzt `python-dotenv`, `imaplib`, `email`, `icalendar` und `caldav`. `python-dotenv` ist speziell dafür gedacht, Werte aus einer `.env`-Datei zu laden und als Umgebungsvariablen verfügbar zu machen.[cite:162][cite:164]
|
Das Hauptskript heißt `ics_mail_importer_env_v2.py`. Es nutzt:
|
||||||
|
|
||||||
Beispielhafter Programmstart:
|
- `python-dotenv` für `.env`
|
||||||
|
- `imaplib` für IMAP
|
||||||
|
- `email` zum Parsen von Nachrichten und Anhängen
|
||||||
|
- `icalendar` zum Parsen von ICS-Dateien
|
||||||
|
- `caldav` für den Kalendereintrag auf mailbox.org.[cite:162][cite:338]
|
||||||
|
|
||||||
|
Beispielstart:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd ~/ics-importer
|
cd ~/ics-importer
|
||||||
pipenv run python3 ics_mail_importer_env.py
|
pipenv run python3 ics_mail_importer_env_v2.py
|
||||||
```
|
```
|
||||||
|
|
||||||
Das Skript erzeugt oder nutzt dabei unter anderem diese Dateien:
|
|
||||||
|
|
||||||
- `ics_importer.log` für die Programmausgabe
|
|
||||||
- `imported_uids.txt` für den Duplikat-Schutz
|
|
||||||
- optional `cron.log`, wenn die Cron-Ausgabe dorthin umgeleitet wird
|
|
||||||
|
|
||||||
## Erster Testlauf
|
## Erster Testlauf
|
||||||
|
|
||||||
Vor dem Cron-Einsatz sollte das Skript immer einmal manuell gestartet werden:
|
Vor dem Einsatz mit Cron sollte das Skript immer einmal manuell getestet werden:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pipenv run python3 ics_mail_importer_env.py
|
pipenv run python3 ics_mail_importer_env_v2.py
|
||||||
```
|
```
|
||||||
|
|
||||||
Wenn die Zugangsdaten korrekt sind, verbindet sich das Skript mit IMAP, liest passende Anhänge und importiert neue Termine in den angegebenen CalDAV-Kalender.[cite:55][cite:45]
|
Dabei sollte geprüft werden:
|
||||||
|
|
||||||
|
- IMAP-Anmeldung funktioniert
|
||||||
|
- der richtige Ordner wird gelesen
|
||||||
|
- ICS-Anhänge werden erkannt
|
||||||
|
- Termine landen im gewünschten mailbox.org-Kalender
|
||||||
|
- nur erfolgreich verarbeitete ICS-Mails werden als gelesen markiert
|
||||||
|
|
||||||
## Cron-Einrichtung
|
## Cron-Einrichtung
|
||||||
|
|
||||||
Den Python-Pfad des Pipenv-Umfelds ermitteln:
|
Interpreter-Pfad aus `pipenv` ermitteln:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd ~/ics-importer
|
cd ~/ics-importer
|
||||||
pipenv --py
|
pipenv --py
|
||||||
```
|
```
|
||||||
|
|
||||||
Stündlicher Cron-Eintrag:
|
Beispiel für stündliche Ausführung:
|
||||||
|
|
||||||
```cron
|
```cron
|
||||||
0 * * * * /home/hans/.local/share/virtualenvs/ics-importer-wOz4rK-o/bin/python /home/hans/ics-importer/ics_mail_importer_env.py >> /home/hans/ics-importer/cron.log 2>&1
|
0 * * * * /home/hans/.local/share/virtualenvs/ics-importer-wOz4rK-o/bin/python /home/hans/ics-importer/ics_mail_importer_env_v2.py >> /home/hans/ics-importer/cron.log 2>&1
|
||||||
```
|
```
|
||||||
|
|
||||||
Die User-Crontab wird mit `crontab -e` gepflegt; die direkte Bearbeitung der Crontab-Dateien ist nicht empfehlenswert. Für stündliche Jobs sind sowohl `0 * * * *` als auch `@hourly` übliche Varianten.[cite:321][cite:55]
|
Die User-Crontab wird mit `crontab -e` gepflegt. Für stündliche Jobs sind `0 * * * *` oder `@hourly` die üblichen Varianten.
|
||||||
|
|
||||||
## Logrotation
|
## Logrotation
|
||||||
|
|
||||||
Wenn `cron.log` mitgeschrieben wird, sollte die Datei per `logrotate` rotiert werden. Eine typische Konfiguration unter `/etc/logrotate.d/ics-importer` sieht so aus:
|
Wenn `cron.log` verwendet wird, sollte die Datei per `logrotate` rotiert werden. Beispiel für `/etc/logrotate.d/ics-importer`:
|
||||||
|
|
||||||
```conf
|
```conf
|
||||||
/home/hans/ics-importer/cron.log {
|
/home/hans/ics-importer/cron.log {
|
||||||
@@ -152,32 +166,32 @@ Wenn `cron.log` mitgeschrieben wird, sollte die Datei per `logrotate` rotiert we
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Damit wird das Log wöchentlich rotiert, vier Versionen bleiben erhalten und ältere Logs werden komprimiert.
|
Diese Konfiguration rotiert wöchentlich, hält vier alte Versionen vor und komprimiert ältere Logs.
|
||||||
|
|
||||||
## mailbox.org in Thunderbird
|
## Thunderbird und mailbox.org
|
||||||
|
|
||||||
Für das eigentliche Projekt ist Thunderbird nicht zwingend nötig, aber mailbox.org empfiehlt für E-Mail in Thunderbird IMAP und für Kalender CalDAV.[cite:55][cite:45]
|
Thunderbird ist für dieses Projekt nicht zwingend nötig, aber mailbox.org empfiehlt IMAP für Mail und CalDAV für Kalender.[cite:45][cite:55]
|
||||||
|
|
||||||
### IMAP in Thunderbird
|
### IMAP in Thunderbird
|
||||||
|
|
||||||
Thunderbird erkennt mailbox.org in der Regel automatisch; empfohlen wird IMAP statt POP3.[cite:55][cite:63]
|
Thunderbird erkennt mailbox.org häufig automatisch; IMAP ist der empfohlene Modus.[cite:55]
|
||||||
|
|
||||||
### CalDAV in Thunderbird
|
### CalDAV in Thunderbird
|
||||||
|
|
||||||
Kalender lassen sich in Thunderbird über **Datei → Neu → Kalender → Im Netzwerk → CalDAV** einbinden. Als URL dient die vollständige mailbox.org-CalDAV-Adresse des Kalenders.[cite:45][cite:51]
|
Kalender lassen sich in Thunderbird über **Datei → Neu → Kalender → Im Netzwerk → CalDAV** einbinden. Die vollständige CalDAV-Adresse ist in mailbox.org beim Kalender über **Eigenschaften** sichtbar.[cite:45][cite:355]
|
||||||
|
|
||||||
In manchen Setups muss in Thunderbird zusätzlich `calendar.network.multirealm` auf `true` gesetzt werden, damit die Authentifizierung sauber funktioniert.[cite:45][cite:51]
|
Falls erforderlich, kann in Thunderbird zusätzlich `calendar.network.multirealm=true` gesetzt werden, um Authentifizierungsprobleme zu vermeiden.[cite:45][cite:64]
|
||||||
|
|
||||||
## Sicherheit
|
## Sicherheit
|
||||||
|
|
||||||
`.env` enthält Zugangsdaten im Klartext und darf nicht in ein öffentliches Repository eingecheckt werden. `python-dotenv` ist für lokale Konfigurationsdateien gedacht, ersetzt aber kein Secret-Management-System.[cite:162][cite:164]
|
`.env` enthält Zugangsdaten im Klartext und darf nicht öffentlich geteilt oder in Git eingecheckt werden. `python-dotenv` ist für lokale Konfiguration gedacht, ersetzt aber kein zentrales Secret-Management.[cite:162][cite:164]
|
||||||
|
|
||||||
Empfehlungen:
|
Empfehlungen:
|
||||||
|
|
||||||
- `.env` nur lokal speichern
|
- `.env` nur lokal speichern
|
||||||
- `chmod 600 .env` setzen
|
- `chmod 600 .env` setzen
|
||||||
- `.env` in `.gitignore` eintragen
|
- `.env` in `.gitignore` eintragen
|
||||||
- Bei aktiver 2FA App-/Applikationspasswörter statt des Hauptpassworts verwenden.[cite:63][cite:45]
|
- bei aktiver 2FA App-/Applikationspasswörter verwenden.[cite:45][cite:55]
|
||||||
|
|
||||||
## Fehlersuche
|
## Fehlersuche
|
||||||
|
|
||||||
@@ -188,25 +202,29 @@ Prüfen:
|
|||||||
- `IMAP_HOST=imap.mailbox.org`
|
- `IMAP_HOST=imap.mailbox.org`
|
||||||
- `IMAP_PORT=993`
|
- `IMAP_PORT=993`
|
||||||
- vollständige E-Mail-Adresse als Benutzername
|
- vollständige E-Mail-Adresse als Benutzername
|
||||||
- korrektes Passwort oder App-Passwort bei aktiver 2FA.[cite:56][cite:63]
|
- App-Passwort bei aktiver 2FA
|
||||||
|
- richtiger Ordner in `IMAP_FOLDER`.[cite:55]
|
||||||
|
|
||||||
### CalDAV funktioniert nicht
|
### CalDAV funktioniert nicht
|
||||||
|
|
||||||
Prüfen:
|
Prüfen:
|
||||||
|
|
||||||
- vollständige CalDAV-URL aus den Kalendereigenschaften in mailbox.org
|
- vollständige CalDAV-URL aus den Kalendereigenschaften
|
||||||
- korrekter Benutzername
|
- richtiger Benutzername
|
||||||
- korrektes Passwort bzw. Applikationspasswort bei aktiver 2FA.[cite:45][cite:51]
|
- Passwort oder Applikationspasswort
|
||||||
|
- Schreibrechte auf den Zielkalender.[cite:45][cite:355]
|
||||||
|
|
||||||
### Cron führt das Skript nicht aus
|
### Mail wird zu früh als gelesen markiert
|
||||||
|
|
||||||
Prüfen:
|
Die Version `ics_mail_importer_env_v2.py` verwendet `BODY.PEEK[]`, damit das bloße Abrufen den `Seen`-Status nicht verändert. Das Lesen-Markieren erfolgt erst danach gezielt per `UID STORE`, und nur wenn der Import erfolgreich war.[cite:338][cite:350][cite:357]
|
||||||
|
|
||||||
- absoluter Python-Pfad aus `pipenv --py`
|
|
||||||
- absoluter Skriptpfad
|
|
||||||
- Schreibrechte für `cron.log`
|
|
||||||
- manueller Testlauf funktioniert bereits
|
|
||||||
|
|
||||||
## Empfohlener Betriebsmodus
|
## Empfohlener Betriebsmodus
|
||||||
|
|
||||||
Für die meisten Setups ist `IMAP_UNSEEN_ONLY=true` sinnvoll, damit nur neue bzw. ungelesene Mails geprüft werden. In Verbindung mit dem Hash-basierten Duplikat-Schutz verhindert das unnötige Doppelimporte und reduziert die Last auf dem Postfach.
|
Für die meisten Setups ist diese Kombination sinnvoll:
|
||||||
|
|
||||||
|
- `IMAP_UNSEEN_ONLY=true`
|
||||||
|
- `IMAP_MARK_AS_READ=true`
|
||||||
|
- stündliche Ausführung per Cron
|
||||||
|
- Duplikat-Schutz über `imported_uids.txt`
|
||||||
|
|
||||||
|
Damit werden nur neue Nachrichten geprüft, unnötige Doppelimporte vermieden und nur tatsächlich erfolgreich verarbeitete ICS-Mails als gelesen markiert.
|
||||||
|
|||||||
@@ -28,3 +28,13 @@
|
|||||||
2026-05-31 09:12:17,331 [INFO] 0 .ics-Anhang/-Anhänge gefunden.
|
2026-05-31 09:12:17,331 [INFO] 0 .ics-Anhang/-Anhänge gefunden.
|
||||||
2026-05-31 09:12:17,331 [INFO] Fertig. 0 Termine insgesamt importiert.
|
2026-05-31 09:12:17,331 [INFO] Fertig. 0 Termine insgesamt importiert.
|
||||||
2026-05-31 09:12:17,331 [INFO] ============================================================
|
2026-05-31 09:12:17,331 [INFO] ============================================================
|
||||||
|
2026-05-31 09:24:11,380 [INFO] ============================================================
|
||||||
|
2026-05-31 09:24:11,381 [INFO] ICS-Importer gestartet (2026-05-31T07:24:11.381101+00:00)
|
||||||
|
2026-05-31 09:24:11,381 [INFO] Verbinde mit IMAP imap.mailbox.org:993 als minitux@mailbox.org ...
|
||||||
|
2026-05-31 09:24:11,719 [INFO] 3 Nachricht(en) zur Prüfung gefunden.
|
||||||
|
2026-05-31 09:24:11,980 [INFO] Prüfe Mail UID 2781 – Betreff: Bilder Filter
|
||||||
|
2026-05-31 09:24:12,015 [INFO] Prüfe Mail UID 2782 – Betreff: AW: Bilder Filter
|
||||||
|
2026-05-31 09:24:12,050 [INFO] Prüfe Mail UID 2804 – Betreff: =?UTF-8?Q?Kurze_Frage_zu_easycosmetic=E2=84=A2?=
|
||||||
|
2026-05-31 09:24:12,116 [INFO] Fertig. 0 Termin(e) importiert.
|
||||||
|
2026-05-31 09:24:12,116 [INFO] 0 Mail(s) als gelesen markiert.
|
||||||
|
2026-05-31 09:24:12,116 [INFO] ============================================================
|
||||||
|
|||||||
@@ -0,0 +1,210 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
ics_mail_importer_env_v2.py
|
||||||
|
---------------------------
|
||||||
|
Sucht in einem mailbox.org IMAP-Postfach nach E-Mails mit .ics-Anhängen
|
||||||
|
und importiert die enthaltenen Termine automatisch in einen CalDAV-Kalender.
|
||||||
|
|
||||||
|
Wichtige Eigenschaften:
|
||||||
|
- Konfiguration über .env-Datei mit python-dotenv
|
||||||
|
- Duplikat-Schutz über Hash der ICS-Anhänge
|
||||||
|
- E-Mails werden beim Abrufen nicht automatisch als gelesen markiert
|
||||||
|
- Nur E-Mails mit erfolgreich verarbeitetem ICS-Anhang werden optional als gelesen markiert
|
||||||
|
"""
|
||||||
|
|
||||||
|
import imaplib
|
||||||
|
import email
|
||||||
|
import logging
|
||||||
|
import hashlib
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
import caldav
|
||||||
|
from icalendar import Calendar
|
||||||
|
|
||||||
|
BASE_DIR = Path(__file__).resolve().parent
|
||||||
|
load_dotenv(BASE_DIR / ".env")
|
||||||
|
|
||||||
|
LOG_FILE = BASE_DIR / "ics_importer.log"
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.INFO,
|
||||||
|
format="%(asctime)s [%(levelname)s] %(message)s",
|
||||||
|
handlers=[logging.FileHandler(LOG_FILE), logging.StreamHandler()],
|
||||||
|
)
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
SEEN_FILE = BASE_DIR / "imported_uids.txt"
|
||||||
|
|
||||||
|
|
||||||
|
def load_seen_uids():
|
||||||
|
if not SEEN_FILE.exists():
|
||||||
|
return set()
|
||||||
|
return set(SEEN_FILE.read_text().splitlines())
|
||||||
|
|
||||||
|
|
||||||
|
def save_uid(uid: str):
|
||||||
|
with SEEN_FILE.open("a") as f:
|
||||||
|
f.write(uid + "\n")
|
||||||
|
|
||||||
|
|
||||||
|
def env_bool(name: str, default: str = "false") -> bool:
|
||||||
|
return os.getenv(name, default).strip().lower() in {"1", "true", "yes", "on"}
|
||||||
|
|
||||||
|
|
||||||
|
def import_ics_to_caldav(ics_bytes: bytes, uid_hash: str) -> int:
|
||||||
|
caldav_url = os.getenv("CALDAV_URL")
|
||||||
|
username = os.getenv("CALDAV_USERNAME")
|
||||||
|
password = os.getenv("CALDAV_PASSWORD")
|
||||||
|
|
||||||
|
if not caldav_url or not username or not password:
|
||||||
|
raise RuntimeError(
|
||||||
|
"CALDAV_URL, CALDAV_USERNAME oder CALDAV_PASSWORD fehlt in .env"
|
||||||
|
)
|
||||||
|
|
||||||
|
client = caldav.DAVClient(url=caldav_url, username=username, password=password)
|
||||||
|
calendar = client.calendar(url=caldav_url)
|
||||||
|
cal = Calendar.from_ical(ics_bytes)
|
||||||
|
|
||||||
|
imported = 0
|
||||||
|
for component in cal.walk():
|
||||||
|
if component.name != "VEVENT":
|
||||||
|
continue
|
||||||
|
|
||||||
|
single_cal = Calendar()
|
||||||
|
single_cal.add("prodid", "-//ics_mail_importer//DE")
|
||||||
|
single_cal.add("version", "2.0")
|
||||||
|
single_cal.add_component(component)
|
||||||
|
|
||||||
|
uid = str(component.get("uid", uid_hash + f"-{imported}"))
|
||||||
|
try:
|
||||||
|
calendar.add_event(single_cal.to_ical().decode("utf-8"))
|
||||||
|
log.info(
|
||||||
|
f" ✓ Termin importiert: {component.get('summary', '(kein Titel)')} [{uid[:20]}]"
|
||||||
|
)
|
||||||
|
imported += 1
|
||||||
|
except Exception as e:
|
||||||
|
log.warning(f" ✗ Fehler beim Import von {uid[:20]}: {e}")
|
||||||
|
|
||||||
|
return imported
|
||||||
|
|
||||||
|
|
||||||
|
def process_mailbox():
|
||||||
|
host = os.getenv("IMAP_HOST", "imap.mailbox.org")
|
||||||
|
port = int(os.getenv("IMAP_PORT", "993"))
|
||||||
|
username = os.getenv("IMAP_USERNAME")
|
||||||
|
password = os.getenv("IMAP_PASSWORD")
|
||||||
|
folder = os.getenv("IMAP_FOLDER", "INBOX")
|
||||||
|
|
||||||
|
if not username or not password:
|
||||||
|
raise RuntimeError("IMAP_USERNAME oder IMAP_PASSWORD fehlt in .env")
|
||||||
|
|
||||||
|
unseen_only = env_bool("IMAP_UNSEEN_ONLY", "true")
|
||||||
|
mark_as_read = env_bool("IMAP_MARK_AS_READ", "false")
|
||||||
|
|
||||||
|
log.info(f"Verbinde mit IMAP {host}:{port} als {username} ...")
|
||||||
|
conn = imaplib.IMAP4_SSL(host, port)
|
||||||
|
conn.login(username, password)
|
||||||
|
|
||||||
|
conn.select(folder)
|
||||||
|
criteria = "UNSEEN" if unseen_only else "ALL"
|
||||||
|
typ, data = conn.uid("search", None, criteria)
|
||||||
|
if typ != "OK":
|
||||||
|
raise RuntimeError("IMAP-Suche fehlgeschlagen")
|
||||||
|
|
||||||
|
message_uids = data[0].split()
|
||||||
|
log.info(f"{len(message_uids)} Nachricht(en) zur Prüfung gefunden.")
|
||||||
|
|
||||||
|
imported_hashes = load_seen_uids()
|
||||||
|
total_imported = 0
|
||||||
|
processed_mail_count = 0
|
||||||
|
|
||||||
|
for msg_uid in message_uids:
|
||||||
|
typ, fetched = conn.uid("fetch", msg_uid, "(BODY.PEEK[])")
|
||||||
|
if typ != "OK" or not fetched or not fetched[0]:
|
||||||
|
log.warning(f"Konnte Nachricht UID {msg_uid.decode()} nicht abrufen.")
|
||||||
|
continue
|
||||||
|
|
||||||
|
raw = fetched[0][1]
|
||||||
|
msg = email.message_from_bytes(raw)
|
||||||
|
|
||||||
|
mail_had_ics = False
|
||||||
|
mail_had_successful_import = False
|
||||||
|
|
||||||
|
subject = msg.get("Subject", "(ohne Betreff)")
|
||||||
|
log.info(f"Prüfe Mail UID {msg_uid.decode()} – Betreff: {subject}")
|
||||||
|
|
||||||
|
for part in msg.walk():
|
||||||
|
content_type = part.get_content_type()
|
||||||
|
filename = part.get_filename() or ""
|
||||||
|
is_ics = (
|
||||||
|
content_type in ("text/calendar", "application/ics")
|
||||||
|
or filename.lower().endswith(".ics")
|
||||||
|
)
|
||||||
|
if not is_ics:
|
||||||
|
continue
|
||||||
|
|
||||||
|
ics_bytes = part.get_payload(decode=True)
|
||||||
|
if not ics_bytes:
|
||||||
|
continue
|
||||||
|
|
||||||
|
mail_had_ics = True
|
||||||
|
uid_hash = hashlib.sha256(ics_bytes).hexdigest()
|
||||||
|
|
||||||
|
if uid_hash in imported_hashes:
|
||||||
|
log.info(
|
||||||
|
f" Überspringe bereits importierten ICS-Anhang {filename!r} ({uid_hash[:12]}…)"
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
log.info(f" ICS-Anhang gefunden: {filename!r} ({uid_hash[:12]}…)")
|
||||||
|
|
||||||
|
try:
|
||||||
|
imported_count = import_ics_to_caldav(ics_bytes, uid_hash)
|
||||||
|
if imported_count > 0:
|
||||||
|
save_uid(uid_hash)
|
||||||
|
imported_hashes.add(uid_hash)
|
||||||
|
total_imported += imported_count
|
||||||
|
mail_had_successful_import = True
|
||||||
|
else:
|
||||||
|
log.warning(
|
||||||
|
f" Kein VEVENT importiert für Anhang {filename!r}; Mail bleibt ungelesen."
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
log.warning(
|
||||||
|
f" Fehler beim Verarbeiten von {filename!r}: {e}; Mail bleibt ungelesen."
|
||||||
|
)
|
||||||
|
|
||||||
|
if mark_as_read and mail_had_successful_import:
|
||||||
|
store_typ, _ = conn.uid("store", msg_uid, "+FLAGS", "(\\Seen)")
|
||||||
|
if store_typ == "OK":
|
||||||
|
processed_mail_count += 1
|
||||||
|
log.info(
|
||||||
|
f"Mail UID {msg_uid.decode()} erfolgreich verarbeitet und als gelesen markiert."
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
log.warning(
|
||||||
|
f"Mail UID {msg_uid.decode()} wurde importiert, konnte aber nicht als gelesen markiert werden."
|
||||||
|
)
|
||||||
|
elif mark_as_read and mail_had_ics and not mail_had_successful_import:
|
||||||
|
log.info(
|
||||||
|
f"Mail UID {msg_uid.decode()} hatte ICS-Anhang, aber keinen erfolgreichen Import; bleibt ungelesen."
|
||||||
|
)
|
||||||
|
|
||||||
|
conn.close()
|
||||||
|
conn.logout()
|
||||||
|
|
||||||
|
log.info(f"Fertig. {total_imported} Termin(e) importiert.")
|
||||||
|
log.info(f"{processed_mail_count} Mail(s) als gelesen markiert.")
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
log.info("=" * 60)
|
||||||
|
log.info(f"ICS-Importer gestartet ({datetime.now(timezone.utc).isoformat()})")
|
||||||
|
process_mailbox()
|
||||||
|
log.info("=" * 60)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Reference in New Issue
Block a user