commit f1f62572a946bbc7d29bc8ed41f42aa9edc4d233 Author: nixbi93 Date: Wed Oct 1 11:31:37 2025 +0000 Dateien nach "/" hochladen diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..c49688a --- /dev/null +++ b/Dockerfile @@ -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"] + diff --git a/Dockerfile-oneshot b/Dockerfile-oneshot new file mode 100644 index 0000000..305b3ac --- /dev/null +++ b/Dockerfile-oneshot @@ -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"] + diff --git a/README.md b/README.md new file mode 100644 index 0000000..b597e3c --- /dev/null +++ b/README.md @@ -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! + diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..b320ba1 --- /dev/null +++ b/docker-compose.yml @@ -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 diff --git a/mail_parser.py b/mail_parser.py new file mode 100644 index 0000000..9237894 --- /dev/null +++ b/mail_parser.py @@ -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
for HTML rendering + body = body.replace("\r\n", "\n").replace("\r", "\n") + body = body.replace("\n", "
") + 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()