From 7e7ddd7d0e670e31f61870b6f407cf4fe91bac21 Mon Sep 17 00:00:00 2001 From: Egor Date: Tue, 20 Jan 2026 22:45:00 -0800 Subject: [PATCH] improvements and small additions to the server ntfy notification flow --- server/.env.example | 14 ++- server/constants/config.ts | 3 +- server/index.ts | 17 ++- server/modules/protonmail.ts | 33 ++---- server/modules/signal.ts | 23 +++- server/public/admin.css | 102 ++++++++++++++---- server/public/admin.html | 46 ++++---- .../routes/{admin/fragments.ts => admin.ts} | 86 ++++++++++----- server/routes/admin/index.ts | 13 --- server/routes/ntfy.ts | 29 +++-- server/utils/auth.ts | 31 ++++-- server/utils/ip.ts | 18 ++++ 12 files changed, 283 insertions(+), 132 deletions(-) rename server/routes/{admin/fragments.ts => admin.ts} (63%) delete mode 100644 server/routes/admin/index.ts create mode 100644 server/utils/ip.ts diff --git a/server/.env.example b/server/.env.example index 67a5270..a5e945c 100644 --- a/server/.env.example +++ b/server/.env.example @@ -6,11 +6,15 @@ # Default: unset (no authentication required) # API_KEY=your-secret-key-here +# Optional: Allow HTTP connections without HTTPS (LAN-only deployments) +# Default: false (HTTPS required for non-localhost) +# ALLOW_INSECURE_HTTP=true + # Optional: Enable verbose signal-cli logging # Default: false # VERBOSE=true -# Optional: ProtonMail integration - receive email notifications via Signal +# Optional: Proton Mail integration - receive email notifications via Signal # Get credentials by running: docker compose run --rm protonmail-bridge init # Then use 'login' and 'info' commands to get IMAP username/password # BRIDGE_IMAP_USERNAME=your-email@proton.me @@ -19,8 +23,8 @@ # PROTON_BRIDGE_PORT=143 # SUP_TOPIC=Proton Mail -# Optional: Enable Android app integration for ProtonMail notifications -# When enabled, sends special format that SUP Android app intercepts to show custom notifications -# that open ProtonMail app on click. When disabled, ProtonMail notifications appear as plain Signal messages. +# Optional: Enable Android app integration for notifications +# When enabled, messages include app launch codes that SUP Android app intercepts to open +# the relevant app (Proton Mail or Home Assistant) when notification is tapped. # Default: false -# ENABLE_PROTON_ANDROID=true \ No newline at end of file +# ENABLE_ANDROID_INTEGRATION=true \ No newline at end of file diff --git a/server/constants/config.ts b/server/constants/config.ts index 55fea62..46f89b6 100644 --- a/server/constants/config.ts +++ b/server/constants/config.ts @@ -1,6 +1,7 @@ export const PORT = Bun.env.PORT || 8080; export const API_KEY = Bun.env.API_KEY; export const VERBOSE = Bun.env.VERBOSE === 'true'; +export const ALLOW_INSECURE_HTTP = Bun.env.ALLOW_INSECURE_HTTP === 'true'; export const DEVICE_NAME = 'SUP'; @@ -12,4 +13,4 @@ export const BRIDGE_IMAP_PASSWORD = Bun.env.BRIDGE_IMAP_PASSWORD; export const PROTON_BRIDGE_HOST = Bun.env.PROTON_BRIDGE_HOST || 'protonmail-bridge'; export const PROTON_BRIDGE_PORT = Number.parseInt(Bun.env.PROTON_BRIDGE_PORT || '143', 10); export const SUP_TOPIC = Bun.env.SUP_TOPIC || 'Proton Mail'; -export const ENABLE_PROTON_ANDROID = Bun.env.ENABLE_PROTON_ANDROID === 'true'; +export const ENABLE_ANDROID_INTEGRATION = Bun.env.ENABLE_ANDROID_INTEGRATION === 'true'; diff --git a/server/index.ts b/server/index.ts index ac7567c..d81595b 100644 --- a/server/index.ts +++ b/server/index.ts @@ -1,9 +1,10 @@ import { API_KEY, BRIDGE_IMAP_PASSWORD, BRIDGE_IMAP_USERNAME, PORT } from '@/constants/config'; import { cleanupDaemon, initSignal } from '@/modules/signal'; -import { adminRoutes } from '@/routes/admin/index'; +import { adminRoutes } from '@/routes/admin'; import { ntfyRoutes } from '@/routes/ntfy'; import { unifiedPushRoutes } from '@/routes/unifiedpush'; -import { logError, logInfo, logWarn } from '@/utils/log'; +import { getLanIP } from '@/utils/ip'; +import { logError, logInfo, logVerbose, logWarn } from '@/utils/log'; try { await initSignal(); @@ -20,8 +21,8 @@ if (BRIDGE_IMAP_USERNAME && BRIDGE_IMAP_PASSWORD) { const { startProtonMonitor } = await import('./modules/protonmail'); await startProtonMonitor(); } catch (err) { - logError('Failed to start ProtonMail monitor:', err); - logWarn('Continuing without ProtonMail integration'); + logError('Failed to start Proton Mail monitor:', err); + logWarn('Continuing without Proton Mail integration'); } } @@ -45,7 +46,13 @@ const server = Bun.serve({ }, }); -logInfo(`\nSUP running on http://localhost:${server.port} 🚀`); +logInfo(`\nSUP running on:`); +logInfo(` Local: http://localhost:${server.port}`); + +const lanIP = getLanIP(); +if (lanIP) { + logVerbose(` Network: http://${lanIP}:${server.port}\n`); +} process.on('SIGINT', () => { cleanupDaemon(); diff --git a/server/modules/protonmail.ts b/server/modules/protonmail.ts index 6dd1586..8f144f9 100644 --- a/server/modules/protonmail.ts +++ b/server/modules/protonmail.ts @@ -2,8 +2,6 @@ import Imap from 'imap'; import { BRIDGE_IMAP_PASSWORD, BRIDGE_IMAP_USERNAME, - ENABLE_PROTON_ANDROID, - LAUNCH_ENDPOINT_PREFIX, PROTON_BRIDGE_HOST, PROTON_BRIDGE_PORT, SUP_TOPIC, @@ -21,12 +19,12 @@ export async function startProtonMonitor() { logError('Missing required env vars: BRIDGE_IMAP_USERNAME and BRIDGE_IMAP_PASSWORD'); logWarn('Run: docker compose run --rm protonmail-bridge init'); logWarn('Then use `login` and `info` commands to get IMAP credentials'); + return; } - const linked = await hasValidAccount(); - if (!linked) { - logWarn('Signal account not linked. ProtonMail notifications will be skipped.'); + if (!(await hasValidAccount())) { + logWarn('Signal account not linked. Proton Mail notifications will be skipped.'); logWarn('Link your Signal account at /link to enable email notifications.'); } @@ -57,14 +55,10 @@ export async function startProtonMonitor() { register(topicKey, groupId, SUP_TOPIC); } - if (ENABLE_PROTON_ANDROID) { - await sendGroupMessage( - groupId, - `${LAUNCH_ENDPOINT_PREFIX}ch.protonmail.android]\n**${title}**\n${message}`, - ); - } else { - await sendGroupMessage(groupId, `${title}\n${message}`); - } + await sendGroupMessage(groupId, message, { + androidPackage: 'ch.protonmail.android', + title, + }); logVerbose(`Notification sent: ${title}`); } catch (error) { @@ -72,7 +66,7 @@ export async function startProtonMonitor() { } } - function openInbox() { + const openInbox = () => imap.openBox('INBOX', false, (err, box) => { if (err) { logError('Failed to open inbox:', err); @@ -101,13 +95,12 @@ export async function startProtonMonitor() { const nameMatch = rawFrom.match(/^"?([^"<]+)"?\s* { imapConnected = true; @@ -132,11 +125,7 @@ export async function startProtonMonitor() { imap.connect(); - process.on('SIGTERM', () => { - imap.end(); - }); + process.on('SIGTERM', () => imap.end()); - process.on('SIGINT', () => { - imap.end(); - }); + process.on('SIGINT', () => imap.end()); } diff --git a/server/modules/signal.ts b/server/modules/signal.ts index a4ffb91..56626a3 100644 --- a/server/modules/signal.ts +++ b/server/modules/signal.ts @@ -1,5 +1,11 @@ import { rm, unlink } from 'node:fs/promises'; -import { DEVICE_NAME, PORT, VERBOSE } from '@/constants/config'; +import { + DEVICE_NAME, + ENABLE_ANDROID_INTEGRATION, + LAUNCH_ENDPOINT_PREFIX, + PORT, + VERBOSE, +} from '@/constants/config'; import { SIGNAL_CLI, SIGNAL_CLI_DATA, SIGNAL_CLI_SOCKET } from '@/constants/paths'; import type { ListAccountsResult, StartLinkResult, UpdateGroupResult } from '@/types'; import { logError, logInfo, logSuccess, logVerbose, logWarn } from '@/utils/log'; @@ -118,14 +124,23 @@ export async function createGroup(name: string, members: string[] = []) { export async function sendGroupMessage( groupId: string, message: string, - { notifySelf = true }: { notifySelf?: boolean } = {}, + options?: { androidPackage?: string; title?: string }, ) { + let formattedMessage = message; + + if (ENABLE_ANDROID_INTEGRATION && options?.androidPackage) { + const title = options.title ? `**${options.title}**\n` : ''; + formattedMessage = `${LAUNCH_ENDPOINT_PREFIX}${options.androidPackage}]\n${title}${message}`; + } else if (options?.title) { + formattedMessage = `${options.title}\n${message}`; + } + await call( 'send', { groupId, - message, - 'notify-self': notifySelf, + message: formattedMessage, + 'notify-self': true, }, account, ); diff --git a/server/public/admin.css b/server/public/admin.css index 6f48dd8..b7cb953 100644 --- a/server/public/admin.css +++ b/server/public/admin.css @@ -44,33 +44,33 @@ h6 { /* Styles */ body { font-family: system-ui; - max-width: 800px; - margin: 20px auto; - padding: 20px; + max-width: 50rem; + margin: 1.25rem auto; + padding: 1.25rem; background: #f5f5f5; } .card { background: white; - border-radius: 8px; - padding: 20px; - margin-bottom: 20px; - box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); + border-radius: 0.5rem; + padding: 1.25rem; + margin-bottom: 1.25rem; + box-shadow: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.1); } h2 { - margin-bottom: 20px; + margin-bottom: 1.25rem; } .status { display: flex; - gap: 15px; + gap: 1rem; flex-wrap: wrap; } .status-item { - padding: 10px 16px; - border-radius: 4px; + padding: 0.625rem 1rem; + border-radius: 0.25rem; font-weight: 500; } @@ -87,26 +87,88 @@ h2 { .endpoint-list { list-style: none; padding: 0; - max-height: 300px; + max-height: 18.75rem; overflow-y: auto; } -.endpoint-list li { - padding: 8px 12px; +.endpoint-item { + display: flex; + align-items: center; + padding: 0.5rem 0.75rem; background: #f8f9fa; - border-radius: 4px; - margin-bottom: 6px; - font-size: 0.9em; + border-radius: 0.25rem; + margin-bottom: 0.375rem; +} + +.endpoint-name { + font-size: 1.1em; + flex: 1; +} + +.btn-delete { + padding: 0.375rem 0.75rem; + background: #dc3545; + color: white; + border: none; + border-radius: 0.25rem; + cursor: pointer; +} + +.btn-delete:hover { + background: #c82333; +} + +.btn-cancel { + margin-top: 1rem; + padding: 0.5rem 1rem; + background: #6c757d; + color: white; + border: none; + border-radius: 0.25rem; + cursor: pointer; +} + +.btn-cancel:hover { + background: #5a6268; +} + +.unlink-details { + margin-top: 1rem; +} + +.unlink-summary { + cursor: pointer; + font-weight: bold; +} + +.unlink-instructions { + margin-top: 1rem; +} + +.unlink-instructions ol { + margin-left: 1.25rem; +} + +.qr-instructions { + font-size: 1.05em; +} + +.qr-container { + margin-top: 1rem; +} + +.qr-image { + max-width: 18.75rem; } .link-button { display: inline-block; - padding: 10px 20px; + padding: 0.625rem 1.25rem; background: #007bff; color: white; text-decoration: none; - border-radius: 4px; - margin-top: 10px; + border-radius: 0.25rem; + margin-top: 0.625rem; border: none; cursor: pointer; } diff --git a/server/public/admin.html b/server/public/admin.html index 7463dfa..acba49b 100644 --- a/server/public/admin.html +++ b/server/public/admin.html @@ -8,7 +8,7 @@ - +
diff --git a/server/routes/admin/fragments.ts b/server/routes/admin.ts similarity index 63% rename from server/routes/admin/fragments.ts rename to server/routes/admin.ts index fc0f8c4..35c0d9c 100644 --- a/server/routes/admin/fragments.ts +++ b/server/routes/admin.ts @@ -11,10 +11,10 @@ import { restartDaemon, unlinkDevice, } from '@/modules/signal'; -import { getAllMappings } from '@/modules/store'; +import { getAllMappings, remove } from '@/modules/store'; import { withAuth } from '@/utils/auth'; -const handleHealthFragment = async () => { +export const handleHealthFragment = async () => { const signalOk = await checkSignalCli(); const linked = signalOk && (await hasValidAccount()); const imap = isImapConnected(); @@ -28,7 +28,7 @@ const handleHealthFragment = async () => { Account: ${linked ? 'Linked' : 'Unlinked'}
- ProtonMail: ${imap ? 'Connected' : 'Disconnected'} + Proton Mail: ${imap ? 'Connected' : 'Disconnected'}
`; @@ -38,14 +38,12 @@ const handleHealthFragment = async () => { }); }; -const handleSignalInfoFragment = async () => { - const linked = await hasValidAccount(); - - const html = linked - ? `
- Unlink and remove device -
-
    +export const handleSignalInfoFragment = async () => { + const html = (await hasValidAccount()) + ? `