Dateien nach "/" hochladen
This commit is contained in:
27
Dockerfile
Normal file
27
Dockerfile
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
FROM python:3.12-alpine
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install curl, use it to install Supercronic, then remove curl
|
||||||
|
ENV SUPERCRONIC_URL=https://github.com/aptible/supercronic/releases/download/v0.2.33/supercronic-linux-amd64 \
|
||||||
|
SUPERCRONIC_SHA1SUM=71b0d58cc53f6bd72cf2f293e09e294b79c666d8 \
|
||||||
|
SUPERCRONIC=supercronic-linux-amd64
|
||||||
|
|
||||||
|
RUN apk add --no-cache curl && \
|
||||||
|
curl -fsSLO "$SUPERCRONIC_URL" && \
|
||||||
|
echo "${SUPERCRONIC_SHA1SUM} ${SUPERCRONIC}" | sha1sum -c - && \
|
||||||
|
chmod +x "$SUPERCRONIC" && \
|
||||||
|
mv "$SUPERCRONIC" "/usr/local/bin/${SUPERCRONIC}" && \
|
||||||
|
ln -s "/usr/local/bin/${SUPERCRONIC}" /usr/local/bin/supercronic && \
|
||||||
|
apk del curl # remove curl to save space
|
||||||
|
|
||||||
|
# Install Python dependencies
|
||||||
|
RUN pip install --no-cache-dir requests beautifulsoup4
|
||||||
|
|
||||||
|
# Copy script and cronjob
|
||||||
|
COPY mail_parser.py /app/
|
||||||
|
RUN printf '*/5 * * * * /usr/local/bin/python /app/mail_parser.py\n' > /app/cronjob
|
||||||
|
|
||||||
|
# Run Supercronic with your cron job
|
||||||
|
CMD ["/usr/local/bin/supercronic", "/app/cronjob"]
|
||||||
|
|
||||||
13
Dockerfile-oneshot
Normal file
13
Dockerfile-oneshot
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
FROM python:3.12-alpine
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install Python dependencies
|
||||||
|
RUN pip install --no-cache-dir requests beautifulsoup4
|
||||||
|
|
||||||
|
# Copy script and cronjob
|
||||||
|
COPY mail_parser.py /app/
|
||||||
|
|
||||||
|
# Run the python script
|
||||||
|
CMD ["python", "mail_parser.py"]
|
||||||
|
|
||||||
120
README.md
Normal file
120
README.md
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
|
||||||
|
# 📧 Email Parser for Vikunja
|
||||||
|
|
||||||
|
This script connects to your email inbox and automatically creates tasks in [Vikunja](https://vikunja.io) based on incoming emails.
|
||||||
|
|
||||||
|
- 💌 Reads unread emails via IMAP
|
||||||
|
- 📬 Parses the subject line to determine the target Vikunja project
|
||||||
|
- ✍️ Creates a new task with the email body as the task description
|
||||||
|
- 📎 Supports file attachments (uploaded to the created task)
|
||||||
|
- 🐳 Runs as a Docker container with cron (using Supercronic)
|
||||||
|
- 🐳 Can be run as a job Docker container
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚀 How It Works
|
||||||
|
|
||||||
|
The script scans your inbox for **unread emails**, checks if the subject matches any keyword in `PROJECT_MAPPING`, and creates a task in the matching Vikunja project.
|
||||||
|
|
||||||
|
For example, with this config:
|
||||||
|
|
||||||
|
```env
|
||||||
|
PROJECT_MAPPING='{"support": "3", "dev": "7"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
- An email with subject: `Bug report - support` → creates a task in project ID **3**
|
||||||
|
- An email with subject: `dev: add feature X` → creates a task in project ID **7**
|
||||||
|
|
||||||
|
The matching keyword (`support`, `dev`) is **removed** from the task title.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🛠 Required Environment Variables
|
||||||
|
|
||||||
|
| Variable | Example value | Description |
|
||||||
|
|--------------------|------------------------------------------------|-------------|
|
||||||
|
| `IMAP_SERVER` | `imap.gmail.com` | Your IMAP server hostname |
|
||||||
|
| `EMAIL_ACCOUNT` | `you@example.com` | The email address to check |
|
||||||
|
| `EMAIL_PASSWORD` | `app-password-123` | The password or app password |
|
||||||
|
| `VIKUNJA_API_URL` | `https://vikunja.example.com/api/v1` | Base URL for the Vikunja API |
|
||||||
|
| `VIKUNJA_TOKEN` | `your-bearer-token` | Personal access token from Vikunja |
|
||||||
|
| `PROJECT_MAPPING` | `'{"support": "3", "dev": "7"}'` | JSON object mapping subject keywords to project IDs |
|
||||||
|
|
||||||
|
## 🛠 Optional Environment Variables
|
||||||
|
|
||||||
|
| Variable | Example value | Description |
|
||||||
|
|--------------------|------------------------------------------------|-------------|
|
||||||
|
| `IMAP_FOLDER` | `inbox/todo` | IMAP Path to folder, default is `inbox` |
|
||||||
|
| `DEFAULT_PROJECT` | `1` | Project ID to put any email into (useful only with an IMAP Folder set) |
|
||||||
|
|
||||||
|
> 🔹 **What is a Vikunja Project ID?**
|
||||||
|
> You can find it by opening a project in Vikunja and checking the URL:
|
||||||
|
> `https://vikunja.example.com/projects/3/tasks` → the ID is **3**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🐳 Running in Docker
|
||||||
|
|
||||||
|
### With Cron (default):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker run -d --name vikunja-mail-parser -e IMAP_SERVER=imap.example.com -e EMAIL_ACCOUNT=you@example.com -e EMAIL_PASSWORD=yourpassword -e VIKUNJA_API_URL=https://vikunja.example.com/api/v1 -e VIKUNJA_TOKEN=your-vikunja-token -e PROJECT_MAPPING='{"support": "3", "dev": "7"}' -e IMAP_FOLDER='inbox' --restart unless-stopped weselinka/vikunja-mail-parser
|
||||||
|
```
|
||||||
|
|
||||||
|
### Without Cron (Oneshot):
|
||||||
|
|
||||||
|
There also is a container `weselinka/vikunja-mail-parser:oneshot` that runs the script just once, without using cron.
|
||||||
|
|
||||||
|
To run the oneshot version of the container:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker run --rm -e IMAP_SERVER=imap.example.com -e EMAIL_ACCOUNT=you@example.com -e EMAIL_PASSWORD=yourpassword -e VIKUNJA_API_URL=https://vikunja.example.com/api/v1 -e VIKUNJA_TOKEN=your-vikunja-token -e PROJECT_MAPPING='{"support": "3", "dev": "7"}' -e IMAP_FOLDER='inbox' weselinka/vikunja-mail-parser:oneshot
|
||||||
|
```
|
||||||
|
|
||||||
|
This will execute the script once and exit, without scheduling future runs.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 🐋 Docker Compose
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
vikunja-mail-parser:
|
||||||
|
image: weselinka/vikunja-mail-parser
|
||||||
|
container_name: vikunja-mail-parser
|
||||||
|
environment:
|
||||||
|
IMAP_SERVER: imap.example.com
|
||||||
|
EMAIL_ACCOUNT: you@example.com
|
||||||
|
EMAIL_PASSWORD: yourpassword
|
||||||
|
VIKUNJA_API_URL: https://vikunja.example.com/api/v1
|
||||||
|
VIKUNJA_TOKEN: your-vikunja-token
|
||||||
|
PROJECT_MAPPING: '{"support": "3", "dev": "7"}'
|
||||||
|
IMAP_FOLDER: 'inbox'
|
||||||
|
restart: unless-stopped
|
||||||
|
```
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🛠 Build the Docker Image Locally
|
||||||
|
|
||||||
|
Download the project and inside the project directory run:
|
||||||
|
```bash
|
||||||
|
docker build -t weselinka/vikunja-mail-parser:latest .
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 💡 Notes
|
||||||
|
|
||||||
|
- Emails must be **unread** to be processed.
|
||||||
|
- Attachments will be uploaded to the created task.
|
||||||
|
- Tasks are created with the full email body (formatted with line breaks or HTML).
|
||||||
|
- If no keyword in the subject matches `PROJECT_MAPPING`, the email is **MARKED READ** and ignored.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🙌 Contributions
|
||||||
|
|
||||||
|
Feature requests, issues, or PRs are welcome. Feel free to fork or suggest improvements!
|
||||||
|
|
||||||
15
docker-compose.yml
Normal file
15
docker-compose.yml
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
vikunja-mail-parser:
|
||||||
|
image: weselinka/vikunja-mail-parser
|
||||||
|
container_name: vikunja-mail-parser
|
||||||
|
environment:
|
||||||
|
IMAP_SERVER: imap_server
|
||||||
|
EMAIL_ACCOUNT: imap_email_account
|
||||||
|
EMAIL_PASSWORD: imap_email_password
|
||||||
|
VIKUNJA_API_URL: https://my.vikunjaurl.com/api/v1
|
||||||
|
VIKUNJA_TOKEN: vikunja_token
|
||||||
|
PROJECT_MAPPING: '{"STRING_FOR_EMAIL_SUBJECT": "PROJECT_ID", "STRING_FOR_EMAIL_SUBJECT": "PROJECT_ID"}'
|
||||||
|
IMAP_PATH: 'inbox'
|
||||||
|
restart: unless-stopped
|
||||||
193
mail_parser.py
Normal file
193
mail_parser.py
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
import imaplib
|
||||||
|
import email
|
||||||
|
from email.header import decode_header
|
||||||
|
import requests
|
||||||
|
import re
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from bs4 import BeautifulSoup
|
||||||
|
|
||||||
|
# Load configuration from environment variables
|
||||||
|
IMAP_SERVER = os.getenv('IMAP_SERVER')
|
||||||
|
EMAIL_ACCOUNT = os.getenv('EMAIL_ACCOUNT')
|
||||||
|
EMAIL_PASSWORD = os.getenv('EMAIL_PASSWORD')
|
||||||
|
VIKUNJA_API_URL = os.getenv('VIKUNJA_API_URL')
|
||||||
|
VIKUNJA_TOKEN = os.getenv('VIKUNJA_TOKEN')
|
||||||
|
|
||||||
|
# Load PROJECT_MAPPING from environment variables
|
||||||
|
project_mapping_str = os.getenv('PROJECT_MAPPING')
|
||||||
|
|
||||||
|
# Load IMAP_PATH from environment variables
|
||||||
|
imap_path_str = os.getenv('IMAP_PATH')
|
||||||
|
|
||||||
|
# Load DEFAULT_PROJECT from environment variables
|
||||||
|
default_project = os.getenv('DEFAULT_PROJECT')
|
||||||
|
|
||||||
|
# If PROJECT_MAPPING is defined, parse it; otherwise, use an empty dictionary
|
||||||
|
if project_mapping_str:
|
||||||
|
PROJECT_MAPPING = json.loads(project_mapping_str)
|
||||||
|
else:
|
||||||
|
print("No Projects mapped, check config of env")
|
||||||
|
PROJECT_MAPPING = {}
|
||||||
|
|
||||||
|
# If IMAP_PATH is defined, parse it; otherwise, use an empty string
|
||||||
|
if imap_path_str:
|
||||||
|
IMAP_PATH = imap_path_str
|
||||||
|
else:
|
||||||
|
IMAP_PATH = "inbox"
|
||||||
|
|
||||||
|
# If DEFAULT_PROJECT is defined, parse it; otherwise, use an empty string
|
||||||
|
if default_project:
|
||||||
|
DEFAULT_PROJECT = default_project
|
||||||
|
else:
|
||||||
|
DEFAULT_PROJECT = ""
|
||||||
|
|
||||||
|
ATTACHMENT_DIR = 'attachments'
|
||||||
|
|
||||||
|
# Ensure attachment directory exists
|
||||||
|
os.makedirs(ATTACHMENT_DIR, exist_ok=True)
|
||||||
|
|
||||||
|
def connect_to_email():
|
||||||
|
mail = imaplib.IMAP4_SSL(IMAP_SERVER)
|
||||||
|
mail.login(EMAIL_ACCOUNT, EMAIL_PASSWORD)
|
||||||
|
mail.select(IMAP_PATH)
|
||||||
|
return mail
|
||||||
|
|
||||||
|
def fetch_unread_emails(mail):
|
||||||
|
status, messages = mail.search(None, "UNSEEN")
|
||||||
|
if status != "OK" or len(messages[0].split()) == 0:
|
||||||
|
print("No new messages to parse.")
|
||||||
|
return []
|
||||||
|
return messages[0].split()
|
||||||
|
|
||||||
|
def parse_email(msg):
|
||||||
|
subject = ""
|
||||||
|
body = ""
|
||||||
|
html_body = ""
|
||||||
|
attachments = []
|
||||||
|
|
||||||
|
# Decode subject
|
||||||
|
if msg["subject"]:
|
||||||
|
decoded_subject, encoding = decode_header(msg["subject"])[0]
|
||||||
|
if isinstance(decoded_subject, bytes):
|
||||||
|
subject = decoded_subject.decode(encoding or "utf-8")
|
||||||
|
else:
|
||||||
|
subject = decoded_subject
|
||||||
|
|
||||||
|
if msg.is_multipart():
|
||||||
|
for part in msg.walk():
|
||||||
|
content_type = part.get_content_type()
|
||||||
|
disposition = part.get("Content-Disposition")
|
||||||
|
|
||||||
|
if content_type == "text/plain" and disposition is None:
|
||||||
|
body = part.get_payload(decode=True).decode(part.get_content_charset() or "utf-8", errors="replace")
|
||||||
|
elif content_type == "text/html" and disposition is None:
|
||||||
|
html_body = part.get_payload(decode=True).decode(part.get_content_charset() or "utf-8", errors="replace")
|
||||||
|
elif disposition and "attachment" in disposition:
|
||||||
|
filename = part.get_filename()
|
||||||
|
if filename:
|
||||||
|
filepath = os.path.join(ATTACHMENT_DIR, filename)
|
||||||
|
with open(filepath, "wb") as f:
|
||||||
|
f.write(part.get_payload(decode=True))
|
||||||
|
attachments.append(filepath)
|
||||||
|
else:
|
||||||
|
content_type = msg.get_content_type()
|
||||||
|
if content_type == "text/plain":
|
||||||
|
body = msg.get_payload(decode=True).decode(msg.get_content_charset() or "utf-8", errors="replace")
|
||||||
|
elif content_type == "text/html":
|
||||||
|
html_body = msg.get_payload(decode=True).decode(msg.get_content_charset() or "utf-8", errors="replace")
|
||||||
|
|
||||||
|
# Prefer HTML as-is, since Vikunja supports it
|
||||||
|
if html_body:
|
||||||
|
body = html_body
|
||||||
|
else:
|
||||||
|
# fallback to plain text with line breaks converted to <br> for HTML rendering
|
||||||
|
body = body.replace("\r\n", "\n").replace("\r", "\n")
|
||||||
|
body = body.replace("\n", "<br>")
|
||||||
|
return subject.strip(), body.strip(), attachments
|
||||||
|
|
||||||
|
|
||||||
|
def determine_project(subject):
|
||||||
|
for keyword, project_id in PROJECT_MAPPING.items():
|
||||||
|
if re.search(keyword, subject, re.IGNORECASE):
|
||||||
|
return project_id, keyword
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
def create_vikunja_task(project_id, title, description):
|
||||||
|
url = f"{VIKUNJA_API_URL}/projects/{project_id}/tasks"
|
||||||
|
headers = {"Authorization": f"Bearer {VIKUNJA_TOKEN}"}
|
||||||
|
payload = {
|
||||||
|
"title": title,
|
||||||
|
"description": description,
|
||||||
|
}
|
||||||
|
|
||||||
|
response = requests.put(url, json=payload, headers=headers)
|
||||||
|
if response.status_code == 201:
|
||||||
|
print(f"Task '{title}' created successfully in project ID {project_id}.")
|
||||||
|
print(f"TaskID: {response.json().get('id')}")
|
||||||
|
return response.json().get("id")
|
||||||
|
else:
|
||||||
|
print(f"Failed to create task. Status: {response.status_code}, Response: {response.json()}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def upload_task_attachments(task_id, attachments):
|
||||||
|
url = f"{VIKUNJA_API_URL}/tasks/{task_id}/attachments"
|
||||||
|
headers = {"Authorization": f"Bearer {VIKUNJA_TOKEN}"}
|
||||||
|
files = [("files", (os.path.basename(filepath), open(filepath, "rb"))) for filepath in attachments]
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = requests.put(url, headers=headers, files=files)
|
||||||
|
if response.status_code == 200:
|
||||||
|
print(f"Attachments uploaded successfully to task ID {task_id}.")
|
||||||
|
else:
|
||||||
|
print(f"Failed to upload attachments. Status: {response.status_code}, Response: {response.json()}")
|
||||||
|
finally:
|
||||||
|
for _, (_, file) in files:
|
||||||
|
file.close()
|
||||||
|
|
||||||
|
def cleanup_attachments(attachments):
|
||||||
|
for filepath in attachments:
|
||||||
|
try:
|
||||||
|
os.remove(filepath)
|
||||||
|
print(f"Deleted attachment: {filepath}")
|
||||||
|
except OSError as e:
|
||||||
|
print(f"Error deleting file {filepath}: {e}")
|
||||||
|
|
||||||
|
def main():
|
||||||
|
mail = connect_to_email()
|
||||||
|
unread_emails = fetch_unread_emails(mail)
|
||||||
|
|
||||||
|
for num in unread_emails:
|
||||||
|
status, data = mail.fetch(num, "(RFC822)")
|
||||||
|
if status != "OK":
|
||||||
|
print(f"Failed to fetch email ID {num}.")
|
||||||
|
continue
|
||||||
|
|
||||||
|
msg = email.message_from_bytes(data[0][1])
|
||||||
|
subject, body, attachments = parse_email(msg)
|
||||||
|
print(f"Processing email with subject: {subject}")
|
||||||
|
|
||||||
|
project_id, keyword = determine_project(subject)
|
||||||
|
if project_id:
|
||||||
|
if keyword:
|
||||||
|
subject = re.sub(keyword, "", subject, flags=re.IGNORECASE).strip()
|
||||||
|
|
||||||
|
task_id = create_vikunja_task(project_id, subject, body)
|
||||||
|
if task_id and attachments:
|
||||||
|
upload_task_attachments(task_id, attachments)
|
||||||
|
cleanup_attachments(attachments)
|
||||||
|
else:
|
||||||
|
print("No matching project found for email subject.")
|
||||||
|
if DEFAULT_PROJECT:
|
||||||
|
project_id = DEFAULT_PROJECT
|
||||||
|
task_id = create_vikunja_task(project_id, subject, body)
|
||||||
|
if task_id and attachments:
|
||||||
|
upload_task_attachments(task_id, attachments)
|
||||||
|
cleanup_attachments(attachments)
|
||||||
|
else:
|
||||||
|
print("No default project ID configured. Skipping email.")
|
||||||
|
|
||||||
|
mail.logout()
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Reference in New Issue
Block a user