From b92ec6474612f732763ab410086dab35f81a9782 Mon Sep 17 00:00:00 2001 From: kriszcode Date: Sat, 10 Jan 2026 12:30:24 +0100 Subject: [PATCH] Add initial project structure with Docker support and configuration files - Create .copilotignore and .dockerignore to exclude config files - Add Dockerfile for building the application - Introduce Makefile for Docker commands - Create ReadMe.md with project description and usage instructions - Add project.env for environment variables - Implement example config.json and localization files (en.json, hu.json) - Develop main application logic in main.js with IMAP monitoring and notification - Define package.json with dependencies and scripts --- .copilotignore | 1 + .dockerignore | 1 + .gitignore | 35 +++++++++ Dockerfile | 18 +++++ Makefile | 47 ++++++++++++ ReadMe.md | 73 +++++++++++++++++++ project.env | 1 + src/config.json.example | 12 ++++ src/i18n.js | 33 +++++++++ src/lang/en.json | 13 ++++ src/lang/hu.json | 16 +++++ src/main.js | 154 ++++++++++++++++++++++++++++++++++++++++ src/package.json | 27 +++++++ 13 files changed, 431 insertions(+) create mode 100644 .copilotignore create mode 100644 .dockerignore create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 Makefile create mode 100644 ReadMe.md create mode 100644 project.env create mode 100755 src/config.json.example create mode 100644 src/i18n.js create mode 100644 src/lang/en.json create mode 100644 src/lang/hu.json create mode 100755 src/main.js create mode 100755 src/package.json diff --git a/.copilotignore b/.copilotignore new file mode 100644 index 0000000..81a57d5 --- /dev/null +++ b/.copilotignore @@ -0,0 +1 @@ +src/config.json \ No newline at end of file diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..0cffcb3 --- /dev/null +++ b/.dockerignore @@ -0,0 +1 @@ +config.json \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c8326cc --- /dev/null +++ b/.gitignore @@ -0,0 +1,35 @@ +# Node +node_modules/ +.npm/ +package-lock.json + +# Build / output +dist/ + +# Runtime / environment +.env +.env.*.local + +# VS Code +.vscode/ +!.vscode/settings.json +!.vscode/extensions.json +!.vscode/launch.json +!.vscode/tasks.json + +# IDEs / editors +.idea/ +*.suo +*.user +*.userosscache +*.sln.docstates + +# OS files +.DS_Store +Thumbs.db + +# project files +config.json + +# Keep .gitkeep files so empty folders can be tracked +!**/.gitkeep \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..5ef20a4 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,18 @@ +# Simple Node.js Dockerfile +FROM node:18-alpine + +WORKDIR /usr/src/app + +# Install dependencies (use package-lock.json if present) +COPY src ./ +RUN npm install --only=production + +# Use a non-root user for safety +RUN addgroup -S app && adduser -S -G app app +USER app + +EXPOSE 3000 +ENV NODE_ENV=production + +# Start the app (change to ["npm","start"] if you use an npm script) +CMD ["node", "main.js"] \ No newline at end of file diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..cd28fa5 --- /dev/null +++ b/Makefile @@ -0,0 +1,47 @@ +# Makefile for ImapNotification +ROOT_DIR := $(shell pwd) + +include $(ROOT_DIR)/project.env +export +PORT ?= 8080 + +.PHONY: all build run stop shell help + +all: build + +build: + @sudo docker build \ + -t $(NAME):local \ + . + +update: + @sudo docker run \ + --rm \ + -it \ + --name $(NAME) \ + -v "$(ROOT_DIR)/src:/usr/src/app" \ + $(NAME):local \ + npm update + . + +run: + @sudo docker run \ + --rm \ + -it \ + --name $(NAME) \ + --env-file $(ROOT_DIR)/.env \ + -v "$(ROOT_DIR)/src:/usr/src/app" \ + $(NAME):local + +stop: + @sudo docker stop $(NAME) + +shell: + docker run --rm -it \ + --name $(CONTAINER)-shell \ + -v "$(shell pwd):$(WORKDIR)" \ + -w $(WORKDIR) \ + $(IMAGE) /bin/sh + +help: + @printf "Usage:\n make build Build local docker image ($(IMAGE))\n make run Run image mounting project into $(WORKDIR) (port $(PORT))\n make stop Stop running container\n make shell Start a shell in a container with project mounted\n\nOverride variables: IMAGE, CONTAINER, WORKDIR, PORT, DOCKER_BUILD_ARGS, DOCKER_RUN_ARGS\n" \ No newline at end of file diff --git a/ReadMe.md b/ReadMe.md new file mode 100644 index 0000000..f488055 --- /dev/null +++ b/ReadMe.md @@ -0,0 +1,73 @@ +# ImapNotification + +Lightweight utility to monitor an IMAP mailbox and emit notifications when new messages arrive. Intended to be used as a local helper, service, or integration point for scripts/webhooks. + +## Features +- Monitor one or more IMAP accounts/folders +- Deliver notifications via command hook, webhook, or stdout +- Support for polling and IMAP IDLE (when available) +- Basic filtering by folder, sender, or subject patterns + +## Quick start + +1. Clone the repository: +``` +git clone +cd ImapNotification +``` + +2. Provide configuration (environment variables or YAML/JSON). Example env vars: +``` +IMAP_HOST=imap.example.com +IMAP_PORT=993 +IMAP_TLS=true +IMAP_USER=user@example.com +IMAP_PASS=yourpassword +MAILBOX=INBOX +POLL_INTERVAL=30 +NOTIFY_COMMAND=/path/to/notify-script.sh +USE_IDLE=true +``` + +Or a minimal YAML example (config.yml): +```yaml +accounts: + - host: imap.example.com + port: 993 + tls: true + user: user@example.com + pass: secret + mailboxes: + - INBOX +settings: + poll_interval: 30 + use_idle: true + notify_command: /usr/local/bin/notify.sh +``` + +3. Run the program: +``` +./imap-notification --config config.yml +``` +(or use the provided run/build instructions for this project) + +## Configuration +- host, port, tls, user, pass: IMAP connection details +- mailboxes: list of folders to monitor +- poll_interval: seconds between checks when not using IDLE +- use_idle: enable IMAP IDLE if server supports it +- notify_command / webhook: how to deliver notifications +- filters: optional sender/subject/folder filters + +## Logging & debugging +- Enable verbose logging to troubleshoot connection or authentication issues. +- Check firewall and IMAP server settings if connections fail. + +## Contributing +- Fork, create a feature branch, add tests, and submit a pull request. +- Keep changes small and documented. Include rationale for new configuration options. + +## License +Specify your license in LICENSE file (e.g., MIT). + +For more detailed usage and examples, see the project docs or examples folder. \ No newline at end of file diff --git a/project.env b/project.env new file mode 100644 index 0000000..98778e1 --- /dev/null +++ b/project.env @@ -0,0 +1 @@ +NAME=imap_notification diff --git a/src/config.json.example b/src/config.json.example new file mode 100755 index 0000000..293fa18 --- /dev/null +++ b/src/config.json.example @@ -0,0 +1,12 @@ +{ + "lang": "en", + "accounts": [ + { + "name": "Example", + "host": "imap.exmaple.com", + "user": "example@example.com", + "pass": "examplepassword", + "topic": "your-ntfy-topic" + } + ] +} \ No newline at end of file diff --git a/src/i18n.js b/src/i18n.js new file mode 100644 index 0000000..7cc9b32 --- /dev/null +++ b/src/i18n.js @@ -0,0 +1,33 @@ +const fs = require('fs'); +const path = require('path'); + +let translations = {}; +let currentLang = 'en'; + +function load(lang) { + try { + const p = path.join(__dirname, 'lang', `${lang}.json`); + const raw = fs.readFileSync(p, 'utf8'); + return JSON.parse(raw); + } catch (e) { + if (lang !== 'en') return load('en'); + return {}; + } +} + +function init(lang) { + currentLang = lang || 'en'; + translations = load(currentLang); +} + +function t(key, vars) { + let str = translations[key] || key; + if (vars) { + Object.keys(vars).forEach(k => { + str = str.replace(new RegExp(`\{${k}\}`, 'g'), vars[k]); + }); + } + return str; +} + +module.exports = { init, t }; \ No newline at end of file diff --git a/src/lang/en.json b/src/lang/en.json new file mode 100644 index 0000000..b2a8186 --- /dev/null +++ b/src/lang/en.json @@ -0,0 +1,13 @@ +{ + "app_started": "ImapNotification application started.", + "message_body": "{fromIcon}: {from}\n{toIcon}: {to}\n{subIcon}: {subject}", + "new_email_title": "New email received", + "ntfy_call_error": "Error calling ntfy:", + "watch_started": "Watching (IDLE) started...", + "watch_error": "An error occurred:", + "conn_closed": "Connection closed, restarting...", + "no_subject": "No subject", + "unknown_recipient": "Unknown recipient", + "no_accounts": "No accounts defined in config.", + "missing_account_params": "Missing required account parameters: host, user, pass, topic, name" +} \ No newline at end of file diff --git a/src/lang/hu.json b/src/lang/hu.json new file mode 100644 index 0000000..06eab6e --- /dev/null +++ b/src/lang/hu.json @@ -0,0 +1,16 @@ +{ + "error_reading_config": "Error reading config file: {path}, message: {message}", + "new_email_title": "Új Email érkezett", + "notif_sent": "Noti elküldve a saját ntfy-nek.", + "ntfy_call_error": "Hiba a belső ntfy hívásnál:", + "watch_started": "Figyelés indítva (IDLE)...", + "new_email_log": "Új email tőle: {from}, neki: {to}, tárgy: {subject}", + "watch_error": "Hiba történt:", +"conn_closed": "Kapcsolat bezárult, újraindítás...", +"app_started": "ImapNotification alkalmazás elindult.", +"message_body": "{fromIcon}: {from}\n{toIcon}: {to}\n{subIcon}: {subject}", +"no_subject": "Nincs tárgy", +"unknown_recipient": "Ismeretlen címzett", +"no_accounts": "Nincs fiók definiálva a konfigurációban.", +"missing_account_params": "Hiányzó kötelező fiók paraméterek: host, user, pass, topic, name" +} \ No newline at end of file diff --git a/src/main.js b/src/main.js new file mode 100755 index 0000000..b8c309a --- /dev/null +++ b/src/main.js @@ -0,0 +1,154 @@ +const { ImapFlow } = require('imapflow'); +const axios = require('axios'); +const fs = require('fs'); +const i18n = require('./i18n'); + +const NTFY_INTERNAL_URL = process.env.NTFY_URL || 'https://ntfy.sh'; +const CONFIG_PATH = process.env.CONFIG_PATH || './config.json'; +const LOG_LEVEL = process.env.LOG_LEVEL || 'info'; +let config; + +const fromIcon = "\uD83D\uDC64"; // 👤 +const toIcon = "\uD83D\uDCE5"; // 📥 +const subjIcon = "\u2709\uFE0F"; // ✉️ + +const log = { + info: (...args) => console.log('[INFO] ', ...args), + error: (...args) => console.error('[ERROR]', ...args), + debug: (...args) => console.debug('[DEBUG]', ...args) +} + +switch (LOG_LEVEL) { + case 'info': + log.debug = () => { }; + break; + case 'error': + log.debug = () => { }; + log.info = () => { }; + break; + case 'debug': + default: + break; +} + +try { + const rawData = fs.readFileSync(CONFIG_PATH); + config = JSON.parse(rawData); +} catch (err) { + log.error(`Error reading config file: ${CONFIG_PATH}, message: `, err.message); + process.exit(1); +} + +i18n.init(config.lang || 'en'); + +async function sendNtfy(acc, subject, from, to) { + try { + + const messageBody = i18n.t('message_body', { + fromIcon: fromIcon, + toIcon: toIcon, + subIcon: subjIcon, + from: from, + to: to, + subject: subject + }); + + log.debug(`Sending ntfy notification for account [${acc.name}] with topic [${acc.topic}]`, messageBody); + + await axios.post(`${NTFY_INTERNAL_URL}`, { + topic: acc.topic, + title: i18n.t('new_email_title'), + message: messageBody, + priority: 5, + tags: ['incoming_envelope'], + click: 'message://' + }) + + } catch (err) { + log.error(i18n.t('ntfy_call_error'), err.message); + } +} + +async function validateAccountParams(params) { + log.debug('Validating account params: ', params); + if (!params.host || !params.user || !params.pass || !params.topic || !params.name) { + throw new Error(i18n.t('missing_account_params')); + } +} + + +async function watchAccount(acc) { + + const client = new ImapFlow({ + host: acc.host, + port: 993, + secure: true, + auth: { + user: acc.user, + pass: acc.pass + }, + logger: (LOG_LEVEL === 'debug' ? log.debug : false) + }); + + const run = async () => { + try { + await client.connect(); + let lock = await client.getMailboxLock('INBOX'); + log.info(`[${acc.name}] ${i18n.t('watch_started')}`); + + client.on('exists', async (data) => { + // Fetch only envelope data (Subject, From) + let message = await client.fetchOne(data.count, { envelope: true }); + + const subject = message.envelope.subject || i18n.t('no_subject'); + const fromInfo = message.envelope.from[0]; + const fromDisplay = fromInfo.name ? fromInfo.name : fromInfo.address; + + // Extract recipient(s) + // The 'envelope.to' is an array because there can be multiple recipients + const toInfo = message.envelope.to && message.envelope.to[0]; + const toAddress = toInfo ? toInfo.address : i18n.t('unknown_recipient'); + + await sendNtfy(acc, subject, fromDisplay, toAddress); + }); + + } catch (err) { + log.error(`[${acc.name}] ${i18n.t('watch_error')}`, err.message); + process.exit(1); + } + }; + + run(); + + client.on('close', () => { + log.info(`[${acc.name}] ${i18n.t('conn_closed')}`); + process.exit(1); + }); +} + +async function run(config) { + + log.info(i18n.t('app_started')); + + if (!Array.isArray(config.accounts) || config.accounts.length === 0) { + log.error(i18n.t('no_accounts')); + process.exit(1); + } + + for (const acc of config.accounts) { + try { + await validateAccountParams(acc); + } catch (err) { + log.error(`[${acc.name || 'Unnamed'}] ${err.message}`); + process.exit(1); + } + } + + for (const acc of config.accounts) { + watchAccount(acc); + } + +} + + +run(config); \ No newline at end of file diff --git a/src/package.json b/src/package.json new file mode 100755 index 0000000..64c39fa --- /dev/null +++ b/src/package.json @@ -0,0 +1,27 @@ +{ + "name": "imapnotification", + "version": "0.1.0", + "description": "IMAP -> ntfy notification bridge", + "main": "index.js", + "type": "commonjs", + "scripts": { + "start": "node index.js", + "dev": "nodemon src/main.ts", + "build": "tsc -p .", + "lint": "eslint . --ext .js,.ts" + }, + "engines": { + "node": ">=18" + }, + "dependencies": { + "imapflow": "^1.0.0", + "axios": "^1.5.0" + }, + "devDependencies": { + "typescript": "^5.0.0", + "ts-node": "^10.0.0", + "nodemon": "^2.0.0", + "eslint": "^8.0.0" + }, + "license": "MIT" +} \ No newline at end of file