From 681cf674c1874cc1c33aea959c2f033980d397de Mon Sep 17 00:00:00 2001 From: Dan Bruce Date: Thu, 15 Jan 2026 17:07:17 -0500 Subject: [PATCH 1/3] update to work with new dispatch emails --- .env.example | 2 +- .gitignore | 2 + package-lock.json | 88 ++++++--- package.json | 3 +- server.js | 488 +++++++++++++++++++++++++++++++--------------- 5 files changed, 400 insertions(+), 183 deletions(-) diff --git a/.env.example b/.env.example index 43bace0..f5e54cc 100644 --- a/.env.example +++ b/.env.example @@ -1,8 +1,8 @@ RECEIVE_EMAIL=test@example.com -FIELDS=["PAGE SENT TO", "INCIDENT", "CALL TYPE", "ADDRESS", "APT / FLR", "LOCATION", "CROSS STREETS", "EMD CODE", "LATITUDE", "LONGITUDE"] SLACK_BOT_TOKEN=xoxb-xxxxxxxxxxxxxxxxxxxxxxxx SLACK_SIGNING_SECRET=randomstringfromslackhere SLACK_CHANNEL=XXXXXXXXX HTTP_PORT=3000 HEADSUP_URL=headsupurlhere(orblanktodisable) HEADSUP_TOKEN=randomstringhere +GOOGLE_MAPS_API_KEY=superlongapikey \ No newline at end of file diff --git a/.gitignore b/.gitignore index 6704566..5aaab25 100644 --- a/.gitignore +++ b/.gitignore @@ -102,3 +102,5 @@ dist # TernJS port file .tern-port + +*.env \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 2b7e13e..a4b1b58 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,10 +10,12 @@ "license": "MIT", "dependencies": { "@slack/bolt": "^3.12.1", + "axios": "^0.21.1", "dotenv": "^16.0.2", "mailparser": "^3.5.0", "nodemailer": "^6.7.8", - "simple-smtp-listener": "^1.0.1" + "simple-smtp-listener": "^1.0.1", + "smtp-server": "^3.18.0" } }, "node_modules/@selderee/plugin-htmlparser2": { @@ -54,6 +56,15 @@ "npm": ">=6.12.0" } }, + "node_modules/@slack/bolt/node_modules/axios": { + "version": "0.26.1", + "resolved": "https://registry.npmjs.org/axios/-/axios-0.26.1.tgz", + "integrity": "sha512-fPwcX4EvnSHuInCMItEhAGnaSEXRBjtzh9fOtsE6E1G6p7vl7edEeZe11QHf18+6+9gR5PbKV/sGKNaD8YaMeA==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.14.8" + } + }, "node_modules/@slack/logger": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/@slack/logger/-/logger-3.0.0.tgz", @@ -329,11 +340,12 @@ "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==" }, "node_modules/axios": { - "version": "0.26.1", - "resolved": "https://registry.npmjs.org/axios/-/axios-0.26.1.tgz", - "integrity": "sha512-fPwcX4EvnSHuInCMItEhAGnaSEXRBjtzh9fOtsE6E1G6p7vl7edEeZe11QHf18+6+9gR5PbKV/sGKNaD8YaMeA==", + "version": "0.21.4", + "resolved": "https://registry.npmjs.org/axios/-/axios-0.21.4.tgz", + "integrity": "sha512-ut5vewkiu8jjGBdqpM44XxjuCjq9LAKeHVmoVfHVzy8eHgxxq8SbAVQNovDA8mVi05kP0Ea/n/UzcSHcTJQfNg==", + "license": "MIT", "dependencies": { - "follow-redirects": "^1.14.8" + "follow-redirects": "^1.14.0" } }, "node_modules/base32.js": { @@ -1686,6 +1698,15 @@ "node": ">= 0.10" } }, + "node_modules/punycode.js": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz", + "integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/qs": { "version": "6.10.3", "resolved": "https://registry.npmjs.org/qs/-/qs-6.10.3.tgz", @@ -1901,22 +1922,25 @@ } }, "node_modules/smtp-server": { - "version": "3.11.0", - "resolved": "https://registry.npmjs.org/smtp-server/-/smtp-server-3.11.0.tgz", - "integrity": "sha512-j/W6mEKeMNKuiM9oCAAjm87agPEN1O3IU4cFLT4ZOCyyq3UXN7HiIXF+q7izxJcYSar15B/JaSxcijoPCR8Tag==", + "version": "3.18.0", + "resolved": "https://registry.npmjs.org/smtp-server/-/smtp-server-3.18.0.tgz", + "integrity": "sha512-xINTnh0H8JDAKOAGSnFX8mgXB/L4Oz8dG4P0EgKAzJEszngxEEx4vOys+yNpsUc6yIyTKS8m2BcIffq4Htma/w==", + "license": "MIT-0", "dependencies": { "base32.js": "0.1.0", "ipv6-normalize": "1.0.1", - "nodemailer": "6.7.3" + "nodemailer": "7.0.11", + "punycode.js": "2.3.1" }, "engines": { - "node": ">=6.0.0" + "node": ">=18.18.0" } }, "node_modules/smtp-server/node_modules/nodemailer": { - "version": "6.7.3", - "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.7.3.tgz", - "integrity": "sha512-KUdDsspqx89sD4UUyUKzdlUOper3hRkDVkrKh/89G+d9WKsU5ox51NWS4tB1XR5dPUdR4SP0E3molyEfOvSa3g==", + "version": "7.0.11", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.11.tgz", + "integrity": "sha512-gnXhNRE0FNhD7wPSCGhdNh46Hs6nm+uTyg+Kq0cZukNQiYdnCsoQjodNP9BQVG9XrcK/v6/MgpAPBUFyzh9pvw==", + "license": "MIT-0", "engines": { "node": ">=6.0.0" } @@ -2100,6 +2124,16 @@ "promise.allsettled": "^1.0.2", "raw-body": "^2.3.3", "tsscmp": "^1.0.6" + }, + "dependencies": { + "axios": { + "version": "0.26.1", + "resolved": "https://registry.npmjs.org/axios/-/axios-0.26.1.tgz", + "integrity": "sha512-fPwcX4EvnSHuInCMItEhAGnaSEXRBjtzh9fOtsE6E1G6p7vl7edEeZe11QHf18+6+9gR5PbKV/sGKNaD8YaMeA==", + "requires": { + "follow-redirects": "^1.14.8" + } + } } }, "@slack/logger": { @@ -2345,11 +2379,11 @@ "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==" }, "axios": { - "version": "0.26.1", - "resolved": "https://registry.npmjs.org/axios/-/axios-0.26.1.tgz", - "integrity": "sha512-fPwcX4EvnSHuInCMItEhAGnaSEXRBjtzh9fOtsE6E1G6p7vl7edEeZe11QHf18+6+9gR5PbKV/sGKNaD8YaMeA==", + "version": "0.21.4", + "resolved": "https://registry.npmjs.org/axios/-/axios-0.21.4.tgz", + "integrity": "sha512-ut5vewkiu8jjGBdqpM44XxjuCjq9LAKeHVmoVfHVzy8eHgxxq8SbAVQNovDA8mVi05kP0Ea/n/UzcSHcTJQfNg==", "requires": { - "follow-redirects": "^1.14.8" + "follow-redirects": "^1.14.0" } }, "base32.js": { @@ -3338,6 +3372,11 @@ "ipaddr.js": "1.9.1" } }, + "punycode.js": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz", + "integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==" + }, "qs": { "version": "6.10.3", "resolved": "https://registry.npmjs.org/qs/-/qs-6.10.3.tgz", @@ -3498,19 +3537,20 @@ } }, "smtp-server": { - "version": "3.11.0", - "resolved": "https://registry.npmjs.org/smtp-server/-/smtp-server-3.11.0.tgz", - "integrity": "sha512-j/W6mEKeMNKuiM9oCAAjm87agPEN1O3IU4cFLT4ZOCyyq3UXN7HiIXF+q7izxJcYSar15B/JaSxcijoPCR8Tag==", + "version": "3.18.0", + "resolved": "https://registry.npmjs.org/smtp-server/-/smtp-server-3.18.0.tgz", + "integrity": "sha512-xINTnh0H8JDAKOAGSnFX8mgXB/L4Oz8dG4P0EgKAzJEszngxEEx4vOys+yNpsUc6yIyTKS8m2BcIffq4Htma/w==", "requires": { "base32.js": "0.1.0", "ipv6-normalize": "1.0.1", - "nodemailer": "6.7.3" + "nodemailer": "7.0.11", + "punycode.js": "2.3.1" }, "dependencies": { "nodemailer": { - "version": "6.7.3", - "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.7.3.tgz", - "integrity": "sha512-KUdDsspqx89sD4UUyUKzdlUOper3hRkDVkrKh/89G+d9WKsU5ox51NWS4tB1XR5dPUdR4SP0E3molyEfOvSa3g==" + "version": "7.0.11", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.11.tgz", + "integrity": "sha512-gnXhNRE0FNhD7wPSCGhdNh46Hs6nm+uTyg+Kq0cZukNQiYdnCsoQjodNP9BQVG9XrcK/v6/MgpAPBUFyzh9pvw==" } } }, diff --git a/package.json b/package.json index 77dca79..1eead27 100644 --- a/package.json +++ b/package.json @@ -23,7 +23,8 @@ "dotenv": "^16.0.2", "mailparser": "^3.5.0", "nodemailer": "^6.7.8", - "simple-smtp-listener": "^1.0.1" + "simple-smtp-listener": "^1.0.1", + "smtp-server": "^3.18.0" }, "overrides": { "simple-smtp-listener": { diff --git a/server.js b/server.js index 5bafd64..1562a90 100644 --- a/server.js +++ b/server.js @@ -1,9 +1,25 @@ -//node packages -const SMTPServer = require("simple-smtp-listener").Server; -const axios = require("axios"); +// A replacement/augmentation of server.js that: +// - validates the SMTP recipient (ensures the message was sent to a specific address) +// - parses the sample dispatch text into fields +// - uses the Google Maps Geocoding API (with a Troy, NY bias) to get lat/long +// - posts a Slack message formatted similarly to server.js +// +// Environment variables required: +// - SLACK_BOT_TOKEN (the bot token used with chat.postMessage) +// - SLACK_CHANNEL (channel id to post to) +// - GOOGLE_MAPS_API_KEY (API key for Google Maps Geocoding API) +// - RECEIVE_EMAIL (the exact recipient address that must be used in RCPT TO) +// - PORT (optional, defaults to 25) +// +// Usage: node server-geocode.js +// +// Note: install dependencies: +// npm install smtp-server dotenv + +const { SMTPServer } = require("smtp-server"); require("dotenv").config(); -//local packages +// local bolt helper (same shape as server.js); adjust path as needed const { app: { client: { @@ -12,199 +28,357 @@ const { }, } = require("./utilities/bolt.js"); -//globals +const SLACK_TOKEN = process.env.SLACK_BOT_TOKEN; +const SLACK_CHANNEL = process.env.SLACK_CHANNEL; +const GOOGLE_MAPS_API_KEY = process.env.GOOGLE_MAPS_API_KEY; const RECEIVE_EMAIL = process.env.RECEIVE_EMAIL; -const FIELDS = JSON.parse(process.env.FIELDS); -const TOKEN = process.env.SLACK_BOT_TOKEN; -const CHANNEL = process.env.SLACK_CHANNEL; -const HEADSUP_URL = process.env.HEADSUP_URL; -const HEADSUP_TOKEN = process.env.HEADSUP_TOKEN; +const PORT = parseInt(process.env.PORT || "25", 10); const { version: VERSION } = require("./package.json"); -//helper functions -const createRegex = () => { - let regexString = "\\s*(?:"; - for (const field of FIELDS) { - regexString += field; - regexString += ")\\s*\\n*|\\s*(?:"; +// Helper: parse a simple "Key: Value" style message into an object. +// Handles blank values and keys with spaces. Also collapses multiple lines into the value if needed. +function parseKeyValueText(text) { + const obj = {}; + // Normalize line endings and split + const lines = text.replace(/\r/g, "").split("\n"); + let currentKey = null; + for (let line of lines) { + line = line.trim(); + if (line === "") { + // preserve blank known keys as empty string (handled by falling through) + continue; + } + const kvMatch = line.match(/^([^:]+):\s*(.*)$/); + if (kvMatch) { + currentKey = kvMatch[1].trim(); + obj[currentKey] = kvMatch[2] || ""; + } else if (currentKey) { + // continuation line for previous key + obj[currentKey] = (obj[currentKey] ? obj[currentKey] + " " : "") + line; + } } - regexString = regexString.slice(0, -7); - const regex = new RegExp(regexString, "g"); - return regex; -}; - -const handleNonDispatch = (text) => { - postMessage({ - token: TOKEN, - channel: CHANNEL, - text: text, - blocks: [ - { - type: "header", - text: { - type: "plain_text", - text: "Message from dispatch:", - emoji: true, - }, - }, - { - type: "section", - text: { - type: "plain_text", - text: text, - }, - }, - ], - unfurl_links: false, - }); -}; - -const handleMessage = ({ text }) => { - if (!text.trim().startsWith("PAGE SENT TO")) { - return handleNonDispatch(text); + return obj; +} + +// Use Google Geocoding API to turn an address into lat/lng. +// We "bias" to Troy, NY by setting components=locality:Troy|administrative_area:NY|country:US +// which will prefer results in Troy, NY. +async function geocodeAddress(address) { + if (!GOOGLE_MAPS_API_KEY) { + throw new Error("GOOGLE_MAPS_API_KEY is not set"); } - const regex = createRegex(); - const data = text.trim().split(regex); - data.shift(); - let info = {}; + const params = { + address: address, + key: GOOGLE_MAPS_API_KEY, + // components acts like a bias/filter: prefer Troy, NY results. + components: "locality:Troy|administrative_area:NY|country:US", + }; - for (const x in data) { - info[FIELDS[x]] = data[x]; - } + const url = "https://maps.googleapis.com/maps/api/geocode/json"; + + try { + const qs = new URLSearchParams(params).toString(); + const resp = await fetch(`${url}?${qs}`); + const data = await resp.json(); - //handle run number - info.INCIDENT = info.INCIDENT.split(/^\d{2}-/)[1]; + if (data.status !== "OK" || !data.results || data.results.length === 0) { + // Try without components as a fallback + const fallbackQs = new URLSearchParams({ address, key: GOOGLE_MAPS_API_KEY }).toString(); + const fallbackResp = await fetch(`${url}?${fallbackQs}`); + if (fallbackResp.ok) { + const fallbackData = await fallbackResp.json(); + if ( + fallbackData && + fallbackData.status === "OK" && + fallbackData.results && + fallbackData.results.length > 0 + ) { + const loc = fallbackData.results[0].geometry.location; + return { lat: loc.lat, lng: loc.lng, place: fallbackData.results[0].formatted_address }; + } + } + return null; + } + const result = data.results[0]; + const loc = result.geometry.location; + return { lat: loc.lat, lng: loc.lng, place: result.formatted_address }; + } catch (err) { + console.error("geocodeAddress error:", err && err.message ? err.message : err); + throw err; + } +} - //handle call type - const origCallTypeSplit = info["CALL TYPE"].split("-"); +// Convert "Call Type: A - Sick Person" into the same CALL TYPE object server.js builds +function parseCallType(callTypeRaw) { + const split = callTypeRaw.split(/\s*-\s*/); let callType; - if (origCallTypeSplit.length == 2) { + if (split.length === 2) { callType = { - determinant: origCallTypeSplit[0], - complaint: origCallTypeSplit[1], + determinant: split[0].trim(), + complaint: split[1].trim(), }; } else { callType = { - determinant: 0, - complaint: info["CALL TYPE"], + determinant: "0", + complaint: callTypeRaw.trim(), }; } - info["CALL TYPE"] = callType; - //handle determinant - switch (info["CALL TYPE"].determinant) { + switch (callType.determinant) { case "A": - info["CALL TYPE"].determinant = "Alpha"; + callType.determinant = "Alpha"; break; case "B": - info["CALL TYPE"].determinant = "Bravo"; + callType.determinant = "Bravo"; break; case "C": - info["CALL TYPE"].determinant = "Charlie"; + callType.determinant = "Charlie"; break; case "D": - info["CALL TYPE"].determinant = "Delta"; + callType.determinant = "Delta"; break; case "E": - info["CALL TYPE"].determinant = "Echo"; + callType.determinant = "Echo"; break; default: - info["CALL TYPE"].determinant = "Unknown"; + // leave as-is (e.g., "0" or unknown) + if (callType.determinant !== "0") { + callType.determinant = "Unknown"; + } else { + callType.determinant = "Unknown"; + } break; } + return callType; +} - //handle lat + long - const origLat = info.LATITUDE; - info.LATITUDE = origLat.slice(0, 2) + "." + origLat.slice(2); - const origLong = info.LONGITUDE; - info.LONGITUDE = "-" + origLong.slice(0, 2) + "." + origLong.slice(2); - - postMessage({ - token: TOKEN, - channel: CHANNEL, - text: `Run number ${info.INCIDENT}: ${info[ - "CALL TYPE" - ].determinant.toLowerCase()} ${info[ - "CALL TYPE" - ].complaint.toLowerCase()} at ${info.LOCATION}`, - blocks: [ - { - type: "header", - text: { - type: "plain_text", - text: `Run number ${info.INCIDENT}`, - emoji: true, - }, - }, - { - type: "section", - text: { - type: "mrkdwn", - text: `Determinant: *${info["CALL TYPE"].determinant}* - \nCategory: *${info["CALL TYPE"].complaint}*`, - }, +// Build Slack message blocks similar to server.js +function buildSlackBlocks(info) { + const coordsText = info.latitude && info.longitude ? ` (${info.latitude}, ${info.longitude})` : ""; + const aptFloor = info["Additional Location Info"] || ""; + const crossStreets = info["Cross Street"] || info["Cross Streets"] || ""; + + const headerText = `Call received: ${info["CALL TYPE"].determinant} - ${info["CALL TYPE"].complaint}`; + + const blocks = [ + { + type: "header", + text: { + type: "plain_text", + text: headerText, + emoji: true, }, - { - type: "section", - text: { - type: "mrkdwn", - text: `Location: *${info.LOCATION}*\nAddress: *${info.ADDRESS}*\n${ - info["APT / FLR"] == "" ? "" : `Apt/floor: *${info["APT / FLR"]}*\n` - }Cross streets: *${info["CROSS STREETS"]}*`, - }, + }, + { + type: "section", + text: { + type: "mrkdwn", + text: `*Location:* ${info.Location}\n*Business:* ${info.Business || "N/A"}`, }, - { - type: "divider", + }, + { + type: "section", + text: { + type: "mrkdwn", + text: `*Cross Streets:* ${crossStreets || "N/A"}\n*Additional Info:* ${ + aptFloor || "N/A" + }`, }, - { - type: "section", - text: { - type: "mrkdwn", - text: "Navigate:", - }, + }, + { + type: "divider", + }, + { + type: "section", + text: { + type: "mrkdwn", + text: `Navigate:\n${info.geocoded_place}`, }, - { - type: "actions", - elements: [ - { - type: "button", - text: { - type: "plain_text", - text: "Apple Maps", - emoji: true, - }, - url: `http://maps.apple.com/?daddr=${info.LATITUDE},${info.LONGITUDE}`, + }, + ]; + + // If we have coordinates add map buttons + if (info.latitude && info.longitude) { + blocks.push({ + type: "actions", + elements: [ + { + type: "button", + text: { + type: "plain_text", + text: "Apple Maps", + emoji: true, }, - { - type: "button", - text: { - type: "plain_text", - text: "Google Maps", - emoji: true, - }, - url: `https://maps.google.com/?daddr=${info.LATITUDE},${info.LONGITUDE}`, + url: `http://maps.apple.com/?daddr=${info.latitude},${info.longitude}`, + }, + { + type: "button", + text: { + type: "plain_text", + text: "Google Maps", + emoji: true, }, - ], - }, - ], - unfurl_links: false, - }); - - if (HEADSUP_URL != "") { - console.log("dispatching to headsup"); - axios - .post(`${HEADSUP_URL}/dispatch?token=${HEADSUP_TOKEN}`, info) - .catch((err) => console.error(err)); + url: `https://maps.google.com/?daddr=${info.latitude},${info.longitude}`, + }, + ], + }); + } + + return blocks; +} + +// Main message handler: parse text, geocode Location, post to Slack +async function handleDispatchText(text) { + try { + const parsed = parseKeyValueText(text); + + // Normalize keys used in server.js style + // Map "Call Type" -> "CALL TYPE", etc. + const info = {}; + for (const k of Object.keys(parsed)) { + info[k] = parsed[k]; + } + + // Parse call type into object + if (info["Call Type"]) { + info["CALL TYPE"] = parseCallType(info["Call Type"]); + } else if (info["CALL TYPE"]) { + info["CALL TYPE"] = parseCallType(info["CALL TYPE"]); + } else { + info["CALL TYPE"] = { determinant: "Unknown", complaint: "Unknown" }; + } + + // Geocode Location + if (info.Location) { + let geocode; + try { + geocode = await geocodeAddress(info.Location); + } catch (err) { + console.warn("Geocoding failed, continuing without coords"); + geocode = null; + } + if (geocode) { + // format lat/long similar to server.js (latitude like 42.7, longitude negative) + info.latitude = geocode.lat; + info.longitude = geocode.lng; + info["geocoded_place"] = geocode.place; + } + + // Strip leading 'RPI -' (or variants like 'RPI-') from Business field, case-insensitive + if (info.Business) { + info.Business = info.Business.replace(/^\s*RPI\s*-\s*/i, "").trim(); + } + } + + // Compose the short text and post to Slack + const shortText = `${info["CALL TYPE"].determinant.toLowerCase()} ${info["CALL TYPE"].complaint.toLowerCase()} at ${info.Location}`; + + await postMessage({ + token: SLACK_TOKEN, + channel: SLACK_CHANNEL, + text: shortText, + blocks: buildSlackBlocks(info), + unfurl_links: false, + }); + + console.log("Posted to Slack:", shortText); + if (info.geocoded_place) { + console.log("Geocoded to:", info.geocoded_place); + } + + // Dispatch to HEADSUP if configured (using fetch, similar to original pattern) + const HEADSUP_URL = process.env.HEADSUP_URL || ""; + const HEADSUP_TOKEN = process.env.HEADSUP_TOKEN || ""; + if (HEADSUP_URL != "") { + console.log(`dispatching to headsup`); + const x = await fetch(`${HEADSUP_URL}/dispatch?token=${HEADSUP_TOKEN}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(info), + }).catch((err) => console.error(err)); + } + } catch (err) { + console.error("handleDispatchText error:", err && err.message ? err.message : err); } -}; +} -const server = new SMTPServer(25); +// Set up SMTP server that only accepts messages sent to RECEIVE_EMAIL +if (!RECEIVE_EMAIL) { + console.error("RECEIVE_EMAIL env var is required"); + process.exit(1); +} +const server = new SMTPServer({ + // Do not require authentication for this example; tighten for production + disabledCommands: ["AUTH"], + // Called for each RCPT TO. Reject if recipient != RECEIVE_EMAIL + onRcptTo(address, session, callback) { + const rcpt = address && address.address ? address.address.toLowerCase() : String(address).toLowerCase(); + if (rcpt === RECEIVE_EMAIL.toLowerCase()) { + return callback(); // accept + } + const err = new Error("550 Recipient not accepted"); + err.responseCode = 550; + return callback(err); + }, + // Collect the message data and run the parser + async onData(stream, session, callback) { + let emailBody = ""; + stream.on("data", (chunk) => { + emailBody += chunk.toString(); + }); + stream.on("end", async () => { + try { + // Very simple extraction: prefer the plain text body if present. + // If the email is raw MIME, attempt to pull after the first blank line + // (headers end) — this is a naive approach for demonstration. + const splitOnDoubleNewline = emailBody.split("\n\n"); + let bodyCandidate = splitOnDoubleNewline.slice(1).join("\n\n").trim(); + if (!bodyCandidate) { + // fallback to entire raw body + bodyCandidate = emailBody; + } + + // If the message contains "Content-Type: text/plain", try to extract that section. + const plainMatch = emailBody.match(/Content-Type:\s*text\/plain[^]*?(?:\r?\n\r?\n)([^]*?)(?:\r?\n--|$)/i); + if (plainMatch && plainMatch[1]) { + bodyCandidate = plainMatch[1].trim(); + } + + // Trim and pass to handler + await handleDispatchText(bodyCandidate); + callback(); + } catch (err) { + console.error("onData processing error:", err && err.message ? err.message : err); + callback(err); + } + }); + }, + logger: false, + // increase size limits if needed + size: 10 * 1024 * 1024, +}); -server.on(RECEIVE_EMAIL, async (mail) => { - handleMessage(await mail); +server.listen(PORT, () => { + console.log(`Dispatch SMTP server listening on port ${PORT}`); + console.log(`Expecting messages sent TO: ${RECEIVE_EMAIL}`); + console.log(`herald v${VERSION} running`); }); -console.log(`headsup v${VERSION} running`); +// For testing: a convenience function that demonstrates parsing the example text and posting to Slack. +// You can call this directly (node server-geocode.js test) to run a one-off parse+post. +const EXAMPLE_TEXT = `Call Type: A - Falls +Location: 51 COLLEGE AVE, TROY CITY +Business: RPI - Darrin Communications Center (DCC) +Additional Location Info: RM 308 +Cross Street: 13TH ST / 8TH ST +Dispatched Units: E59 +Response Areas: Troy FD 2640/Troy EMS 8243`; -console.log(createRegex()); +if (process.argv.includes("test")) { + (async () => { + console.log("Running test parse + geocode of example text..."); + await handleDispatchText(EXAMPLE_TEXT); + process.exit(0); + })(); +} \ No newline at end of file From d100f0939e4fb0b998084a00f8f2ecf604460a51 Mon Sep 17 00:00:00 2001 From: Dan Bruce Date: Thu, 15 Jan 2026 17:07:26 -0500 Subject: [PATCH 2/3] 2.0.0 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index a4b1b58..695910f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "herald", - "version": "1.1.0", + "version": "2.0.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "herald", - "version": "1.1.0", + "version": "2.0.0", "license": "MIT", "dependencies": { "@slack/bolt": "^3.12.1", diff --git a/package.json b/package.json index 1eead27..5e76e12 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "herald", - "version": "1.1.0", + "version": "2.0.0", "description": "A package to parse text message dispatches and send them to Slack", "main": "server.js", "scripts": { From 48e9249b8e543ce29f9df04b98a7f605d0fdffd9 Mon Sep 17 00:00:00 2001 From: Dan Bruce Date: Thu, 15 Jan 2026 17:40:42 -0500 Subject: [PATCH 3/3] better locations --- server.js | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/server.js b/server.js index 1562a90..3ce26dd 100644 --- a/server.js +++ b/server.js @@ -288,6 +288,15 @@ async function handleDispatchText(text) { } // Dispatch to HEADSUP if configured (using fetch, similar to original pattern) + let infoWithGeocodedPlace = { ...info }; + if (info.latitude && info.longitude) { + infoWithGeocodedPlace.Location = info.geocoded_place; + if (info.Business) { + infoWithGeocodedPlace.Location = `${info.Business} - ${infoWithGeocodedPlace.Location}`; + } + } else { + infoWithGeocodedPlace.Location = info.Location || "Unknown - Check dispatch for details"; + } const HEADSUP_URL = process.env.HEADSUP_URL || ""; const HEADSUP_TOKEN = process.env.HEADSUP_TOKEN || ""; if (HEADSUP_URL != "") { @@ -295,7 +304,7 @@ async function handleDispatchText(text) { const x = await fetch(`${HEADSUP_URL}/dispatch?token=${HEADSUP_TOKEN}`, { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify(info), + body: JSON.stringify(infoWithGeocodedPlace), }).catch((err) => console.error(err)); } } catch (err) {