commit b92ec6474612f732763ab410086dab35f81a9782 Author: kriszcode Date: Sat Jan 10 12:30:24 2026 +0100 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 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