HTML in body of mail

This may not be much, however since i took so much time to get it done and accomplished what i wanted, posting it here, for someone who may be interested.
I already had a flow where a temperature report is sent on a daily basis to intended recipients as a pdf file attachment. however i noticed that opening an attachment and looking at the report is really not very user friendly and people would skip it and move on unless they WANTED to see the report. i tried putting the report in the body of the mail, so that the user can see the report as soon as they see the mail, however, i was unable to get the 'charts' on the body, table and other markup text was no issue. i tried very hard but realised that microsoft outlook doesn't accept any html created by dynamic javascript considering it as a threat (or may be it is my office IT policy, not sure). so i turned to AI to help. Perplexity helped me build a flow, which allowed me to create a email body that can be rendered rather beautifully, charts along with tables and other tiles. it suggested to use https://quickchart.io/ API and it was successful.

[{"id":"d02a816e413cefad","type":"inject","z":"9fcb1fec5d4354f5","name":"","props":[{"p":"payload"},{"p":"topic","vt":"str"}],"repeat":"","crontab":"","once":false,"onceDelay":0.1,"topic":"","payload":"[{\"datetime\":\"2025-12-10T00:30:00.000Z\",\"Time\":\"10-Dec 06:00\",\"CR-1\":8,\"CR-2\":-11,\"CR-3\":10.5,\"CR-4\":10.1},{\"datetime\":\"2025-12-10T00:31:00.000Z\",\"Time\":\"10-Dec 06:01\",\"CR-1\":8.3,\"CR-2\":-11,\"CR-3\":10.5,\"CR-4\":10.2},{\"datetime\":\"2025-12-10T00:32:00.000Z\",\"Time\":\"10-Dec 06:02\",\"CR-1\":8.1,\"CR-2\":-11,\"CR-3\":10.5,\"CR-4\":10.6},{\"datetime\":\"2025-12-10T00:33:00.000Z\",\"Time\":\"10-Dec 06:03\",\"CR-1\":8.2,\"CR-2\":-11,\"CR-3\":10.5,\"CR-4\":10.9},{\"datetime\":\"2025-12-10T00:34:00.000Z\",\"Time\":\"10-Dec 06:04\",\"CR-1\":8.1,\"CR-2\":-11,\"CR-3\":10.5,\"CR-4\":11.1},{\"datetime\":\"2025-12-10T00:35:00.000Z\",\"Time\":\"10-Dec 06:05\",\"CR-1\":8.2,\"CR-2\":-11,\"CR-3\":10.5,\"CR-4\":11.2},{\"datetime\":\"2025-12-10T00:36:00.000Z\",\"Time\":\"10-Dec 06:06\",\"CR-1\":8.2,\"CR-2\":-11.1,\"CR-3\":10.5,\"CR-4\":11.4},{\"datetime\":\"2025-12-10T00:37:00.000Z\",\"Time\":\"10-Dec 06:07\",\"CR-1\":8.1,\"CR-2\":-11.1,\"CR-3\":10.5,\"CR-4\":11.5},{\"datetime\":\"2025-12-10T00:38:00.000Z\",\"Time\":\"10-Dec 06:08\",\"CR-1\":8.4,\"CR-2\":-11.1,\"CR-3\":10.5,\"CR-4\":11.6},{\"datetime\":\"2025-12-10T00:39:00.000Z\",\"Time\":\"10-Dec 06:09\",\"CR-1\":8.2,\"CR-2\":-11.2,\"CR-3\":10.5,\"CR-4\":11.8},{\"datetime\":\"2025-12-10T00:40:00.000Z\",\"Time\":\"10-Dec 06:10\",\"CR-1\":8.4,\"CR-2\":-11.6,\"CR-3\":10.5,\"CR-4\":11.9},{\"datetime\":\"2025-12-10T00:41:00.000Z\",\"Time\":\"10-Dec 06:11\",\"CR-1\":8.2,\"CR-2\":-12.3,\"CR-3\":10.5,\"CR-4\":12},{\"datetime\":\"2025-12-10T00:42:00.000Z\",\"Time\":\"10-Dec 06:12\",\"CR-1\":8.2,\"CR-2\":-12.6,\"CR-3\":10.5,\"CR-4\":12.1},{\"datetime\":\"2025-12-10T00:43:01.000Z\",\"Time\":\"10-Dec 06:13\",\"CR-1\":8.3,\"CR-2\":-12,\"CR-3\":10.5,\"CR-4\":12.2},{\"datetime\":\"2025-12-10T00:44:00.000Z\",\"Time\":\"10-Dec 06:14\",\"CR-1\":8.3,\"CR-2\":-11.9,\"CR-3\":10.5,\"CR-4\":12.3},{\"datetime\":\"2025-12-10T00:45:00.000Z\",\"Time\":\"10-Dec 06:15\",\"CR-1\":8.3,\"CR-2\":-11.8,\"CR-3\":10.5,\"CR-4\":12.4},{\"datetime\":\"2025-12-10T00:46:00.000Z\",\"Time\":\"10-Dec 06:16\",\"CR-1\":8.3,\"CR-2\":-11.9,\"CR-3\":10.5,\"CR-4\":12.5},{\"datetime\":\"2025-12-10T00:47:00.000Z\",\"Time\":\"10-Dec 06:17\",\"CR-1\":8,\"CR-2\":-11.8,\"CR-3\":10.5,\"CR-4\":12.6},{\"datetime\":\"2025-12-10T00:48:00.000Z\",\"Time\":\"10-Dec 06:18\",\"CR-1\":8.5,\"CR-2\":-11.9,\"CR-3\":10.5,\"CR-4\":12.6},{\"datetime\":\"2025-12-10T00:49:00.000Z\",\"Time\":\"10-Dec 06:19\",\"CR-1\":8.2,\"CR-2\":-11.9,\"CR-3\":10.5,\"CR-4\":12.7},{\"datetime\":\"2025-12-10T00:50:00.000Z\",\"Time\":\"10-Dec 06:20\",\"CR-1\":8.5,\"CR-2\":-12,\"CR-3\":10.5,\"CR-4\":12.8},{\"datetime\":\"2025-12-10T00:51:00.000Z\",\"Time\":\"10-Dec 06:21\",\"CR-1\":8.3,\"CR-2\":-11.9,\"CR-3\":10.5,\"CR-4\":12.9},{\"datetime\":\"2025-12-10T00:52:00.000Z\",\"Time\":\"10-Dec 06:22\",\"CR-1\":8.4,\"CR-2\":-12,\"CR-3\":10.5,\"CR-4\":13},{\"datetime\":\"2025-12-10T00:53:00.000Z\",\"Time\":\"10-Dec 06:23\",\"CR-1\":8.3,\"CR-2\":-12,\"CR-3\":10.5,\"CR-4\":13},{\"datetime\":\"2025-12-10T00:54:00.000Z\",\"Time\":\"10-Dec 06:24\",\"CR-1\":8.5,\"CR-2\":-12,\"CR-3\":10.5,\"CR-4\":13.1},{\"datetime\":\"2025-12-10T00:55:00.000Z\",\"Time\":\"10-Dec 06:25\",\"CR-1\":8.2,\"CR-2\":-12.1,\"CR-3\":10.5,\"CR-4\":13.2},{\"datetime\":\"2025-12-10T00:56:00.000Z\",\"Time\":\"10-Dec 06:26\",\"CR-1\":8.4,\"CR-2\":-12.1,\"CR-3\":10.5,\"CR-4\":13.2},{\"datetime\":\"2025-12-10T00:57:00.000Z\",\"Time\":\"10-Dec 06:27\",\"CR-1\":8.4,\"CR-2\":-12.1,\"CR-3\":10.5,\"CR-4\":13.3},{\"datetime\":\"2025-12-10T00:58:00.000Z\",\"Time\":\"10-Dec 06:28\",\"CR-1\":8.3,\"CR-2\":-12.2,\"CR-3\":10.5,\"CR-4\":13.3},{\"datetime\":\"2025-12-10T00:59:00.000Z\",\"Time\":\"10-Dec 06:29\",\"CR-1\":8.5,\"CR-2\":-12.1,\"CR-3\":10.5,\"CR-4\":13.4},{\"datetime\":\"2025-12-10T01:00:00.000Z\",\"Time\":\"10-Dec 06:30\",\"CR-1\":8.3,\"CR-2\":-12.2,\"CR-3\":10.5,\"CR-4\":13.4},{\"datetime\":\"2025-12-10T01:01:00.000Z\",\"Time\":\"10-Dec 06:31\",\"CR-1\":8.6,\"CR-2\":-12.2,\"CR-3\":10.4,\"CR-4\":13.5},{\"datetime\":\"2025-12-10T01:02:00.000Z\",\"Time\":\"10-Dec 06:32\",\"CR-1\":8.3,\"CR-2\":-12.2,\"CR-3\":10.4,\"CR-4\":13.1},{\"datetime\":\"2025-12-10T01:03:00.000Z\",\"Time\":\"10-Dec 06:33\",\"CR-1\":8.7,\"CR-2\":-12.3,\"CR-3\":10.4,\"CR-4\":12.6},{\"datetime\":\"2025-12-10T01:04:00.000Z\",\"Time\":\"10-Dec 06:34\",\"CR-1\":8.5,\"CR-2\":-12.3,\"CR-3\":10.4,\"CR-4\":12.3},{\"datetime\":\"2025-12-10T01:05:00.000Z\",\"Time\":\"10-Dec 06:35\",\"CR-1\":8.6,\"CR-2\":-12.3,\"CR-3\":10.4,\"CR-4\":12.2},{\"datetime\":\"2025-12-10T01:06:00.000Z\",\"Time\":\"10-Dec 06:36\",\"CR-1\":8.5,\"CR-2\":-12.3,\"CR-3\":10.4,\"CR-4\":12},{\"datetime\":\"2025-12-10T01:07:00.000Z\",\"Time\":\"10-Dec 06:37\",\"CR-1\":8.6,\"CR-2\":-12.3,\"CR-3\":10.4,\"CR-4\":11.9},{\"datetime\":\"2025-12-10T01:08:00.000Z\",\"Time\":\"10-Dec 06:38\",\"CR-1\":8.5,\"CR-2\":-12.3,\"CR-3\":10.4,\"CR-4\":11.8},{\"datetime\":\"2025-12-10T01:09:00.000Z\",\"Time\":\"10-Dec 06:39\",\"CR-1\":8.4,\"CR-2\":-12.3,\"CR-3\":10.3,\"CR-4\":11.7},{\"datetime\":\"2025-12-10T01:10:00.000Z\",\"Time\":\"10-Dec 06:40\",\"CR-1\":8.5,\"CR-2\":-12.3,\"CR-3\":10.3,\"CR-4\":11.7},{\"datetime\":\"2025-12-10T01:11:00.000Z\",\"Time\":\"10-Dec 06:41\",\"CR-1\":8.4,\"CR-2\":-12.3,\"CR-3\":10.3,\"CR-4\":11.6},{\"datetime\":\"2025-12-10T01:12:00.000Z\",\"Time\":\"10-Dec 06:42\",\"CR-1\":8.7,\"CR-2\":-12.4,\"CR-3\":10.3,\"CR-4\":11.5},{\"datetime\":\"2025-12-10T01:13:00.000Z\",\"Time\":\"10-Dec 06:43\",\"CR-1\":8.4,\"CR-2\":-12.4,\"CR-3\":10.3,\"CR-4\":11.4},{\"datetime\":\"2025-12-10T01:14:00.000Z\",\"Time\":\"10-Dec 06:44\",\"CR-1\":8.6,\"CR-2\":-12.4,\"CR-3\":10.3,\"CR-4\":11.3},{\"datetime\":\"2025-12-10T01:15:00.000Z\",\"Time\":\"10-Dec 06:45\",\"CR-1\":8.4,\"CR-2\":-12.4,\"CR-3\":10.3,\"CR-4\":11.5},{\"datetime\":\"2025-12-10T01:16:00.000Z\",\"Time\":\"10-Dec 06:46\",\"CR-1\":8.8,\"CR-2\":-12.4,\"CR-3\":10.3,\"CR-4\":11.5},{\"datetime\":\"2025-12-10T01:17:00.000Z\",\"Time\":\"10-Dec 06:47\",\"CR-1\":8.5,\"CR-2\":-12.4,\"CR-3\":10.3,\"CR-4\":11.2},{\"datetime\":\"2025-12-10T01:18:00.000Z\",\"Time\":\"10-Dec 06:48\",\"CR-1\":8.8,\"CR-2\":-12.4,\"CR-3\":10.3,\"CR-4\":11.1},{\"datetime\":\"2025-12-10T01:19:00.000Z\",\"Time\":\"10-Dec 06:49\",\"CR-1\":8.5,\"CR-2\":-12.4,\"CR-3\":10.3,\"CR-4\":11.1},{\"datetime\":\"2025-12-10T01:20:00.000Z\",\"Time\":\"10-Dec 06:50\",\"CR-1\":8.7,\"CR-2\":-12.4,\"CR-3\":10.3,\"CR-4\":11},{\"datetime\":\"2025-12-10T01:21:00.000Z\",\"Time\":\"10-Dec 06:51\",\"CR-1\":8.6,\"CR-2\":-12.4,\"CR-3\":10.3,\"CR-4\":10.9},{\"datetime\":\"2025-12-10T01:22:00.000Z\",\"Time\":\"10-Dec 06:52\",\"CR-1\":8.7,\"CR-2\":-12.5,\"CR-3\":10.3,\"CR-4\":10.8},{\"datetime\":\"2025-12-10T01:23:00.000Z\",\"Time\":\"10-Dec 06:53\",\"CR-1\":8.7,\"CR-2\":-12.4,\"CR-3\":10.3,\"CR-4\":10.8},{\"datetime\":\"2025-12-10T01:24:00.000Z\",\"Time\":\"10-Dec 06:54\",\"CR-1\":8.6,\"CR-2\":-12.5,\"CR-3\":10.2,\"CR-4\":10.7},{\"datetime\":\"2025-12-10T01:25:00.000Z\",\"Time\":\"10-Dec 06:55\",\"CR-1\":8.8,\"CR-2\":-12.4,\"CR-3\":10.2,\"CR-4\":10.7},{\"datetime\":\"2025-12-10T01:26:00.000Z\",\"Time\":\"10-Dec 06:56\",\"CR-1\":8.6,\"CR-2\":-12.4,\"CR-3\":10.2,\"CR-4\":10.6},{\"datetime\":\"2025-12-10T01:27:00.000Z\",\"Time\":\"10-Dec 06:57\",\"CR-1\":8.8,\"CR-2\":-12.4,\"CR-3\":10.2,\"CR-4\":10.6},{\"datetime\":\"2025-12-10T01:28:00.000Z\",\"Time\":\"10-Dec 06:58\",\"CR-1\":8.5,\"CR-2\":-12.5,\"CR-3\":10.2,\"CR-4\":10.5},{\"datetime\":\"2025-12-10T01:29:00.000Z\",\"Time\":\"10-Dec 06:59\",\"CR-1\":8.8,\"CR-2\":-12.4,\"CR-3\":10.2,\"CR-4\":10.4},{\"datetime\":\"2025-12-10T01:30:00.000Z\",\"Time\":\"10-Dec 07:00\",\"CR-1\":8.5,\"CR-2\":-12.4,\"CR-3\":10.2,\"CR-4\":10.4},{\"datetime\":\"2025-12-10T01:31:00.000Z\",\"Time\":\"10-Dec 07:01\",\"CR-1\":8.8,\"CR-2\":-12.4,\"CR-3\":10.3,\"CR-4\":10.4},{\"datetime\":\"2025-12-10T01:32:00.000Z\",\"Time\":\"10-Dec 07:02\",\"CR-1\":8.6,\"CR-2\":-12.5,\"CR-3\":10.3,\"CR-4\":10.3},{\"datetime\":\"2025-12-10T01:33:00.000Z\",\"Time\":\"10-Dec 07:03\",\"CR-1\":8.8,\"CR-2\":-12.5,\"CR-3\":10.2,\"CR-4\":10.7},{\"datetime\":\"2025-12-10T01:34:00.000Z\",\"Time\":\"10-Dec 07:04\",\"CR-1\":8.7,\"CR-2\":-12.4,\"CR-3\":10.3,\"CR-4\":10.4},{\"datetime\":\"2025-12-10T01:35:00.000Z\",\"Time\":\"10-Dec 07:05\",\"CR-1\":8.9,\"CR-2\":-12.6,\"CR-3\":10.3,\"CR-4\":10.3},{\"datetime\":\"2025-12-10T01:36:00.000Z\",\"Time\":\"10-Dec 07:06\",\"CR-1\":8.7,\"CR-2\":-12.5,\"CR-3\":10.3,\"CR-4\":10.3},{\"datetime\":\"2025-12-10T01:37:00.000Z\",\"Time\":\"10-Dec 07:07\",\"CR-1\":8.9,\"CR-2\":-12.5,\"CR-3\":10.3,\"CR-4\":10.2},{\"datetime\":\"2025-12-10T01:38:00.000Z\",\"Time\":\"10-Dec 07:08\",\"CR-1\":8.6,\"CR-2\":-12.5,\"CR-3\":10.2,\"CR-4\":10.2},{\"datetime\":\"2025-12-10T01:39:00.000Z\",\"Time\":\"10-Dec 07:09\",\"CR-1\":8.9,\"CR-2\":-12.5,\"CR-3\":10.2,\"CR-4\":10.2},{\"datetime\":\"2025-12-10T01:40:00.000Z\",\"Time\":\"10-Dec 07:10\",\"CR-1\":8.8,\"CR-2\":-12.5,\"CR-3\":10.3,\"CR-4\":10.1},{\"datetime\":\"2025-12-10T01:41:00.000Z\",\"Time\":\"10-Dec 07:11\",\"CR-1\":8.9,\"CR-2\":-12.5,\"CR-3\":10.3,\"CR-4\":10},{\"datetime\":\"2025-12-10T01:42:00.000Z\",\"Time\":\"10-Dec 07:12\",\"CR-1\":8.7,\"CR-2\":-12.6,\"CR-3\":10.2,\"CR-4\":10.6},{\"datetime\":\"2025-12-10T01:43:00.000Z\",\"Time\":\"10-Dec 07:13\",\"CR-1\":9,\"CR-2\":-12.6,\"CR-3\":10.2,\"CR-4\":10.9},{\"datetime\":\"2025-12-10T01:44:00.000Z\",\"Time\":\"10-Dec 07:14\",\"CR-1\":8.8,\"CR-2\":-12.6,\"CR-3\":10.2,\"CR-4\":11},{\"datetime\":\"2025-12-10T01:45:00.000Z\",\"Time\":\"10-Dec 07:15\",\"CR-1\":8.9,\"CR-2\":-12.6,\"CR-3\":10.2,\"CR-4\":11.2},{\"datetime\":\"2025-12-10T01:46:00.000Z\",\"Time\":\"10-Dec 07:16\",\"CR-1\":8.8,\"CR-2\":-12.6,\"CR-3\":10.2,\"CR-4\":11.3}]","payloadType":"json","x":130,"y":1700,"wires":[["4a5aa503c41910cc"]]},{"id":"4a5aa503c41910cc","type":"change","z":"9fcb1fec5d4354f5","name":"Data","rules":[{"t":"set","p":"payload","pt":"msg","to":"payload","tot":"msg"}],"action":"","property":"","from":"","to":"","reg":false,"x":270,"y":1700,"wires":[["9257161960e7a2d3"]]},{"id":"9257161960e7a2d3","type":"function","z":"9fcb1fec5d4354f5","name":"Chart Limits","func":"msg.lsl1= flow.get(\"lsl1\");\nmsg.lsl2= flow.get(\"lsl2\");\nmsg.lsl3= flow.get(\"lsl3\");\nmsg.lsl4= flow.get(\"lsl4\");\n\nmsg.usl1= flow.get(\"usl1\");\nmsg.usl2= flow.get(\"usl2\");\nmsg.usl3= flow.get(\"usl3\");\nmsg.usl4= flow.get(\"usl4\");\n\nmsg.min1=0\nmsg.min2=-20\nmsg.min3=0\nmsg.min4=0\n\nmsg.max1=30\nmsg.max2=0\nmsg.max3=30\nmsg.max4=30\n\nmsg.charttitle1=\"FG Cold Room-1\"\nmsg.charttitle2=\"Frozen Room-1\"\nmsg.charttitle3=\"RM Cold Room-1\"\nmsg.charttitle4=\"WIP Cold Room-1\"\n\n\nreturn msg;","outputs":1,"timeout":0,"noerr":0,"initialize":"","finalize":"","libs":[],"x":430,"y":1700,"wires":[["21ca0829bbb67b56"]]},{"id":"21ca0829bbb67b56","type":"change","z":"9fcb1fec5d4354f5","name":"Variables","rules":[{"t":"set","p":"pagetitle","pt":"msg","to":"COLD ROOM TEMPERATURE REPORT","tot":"str"}],"action":"","property":"","from":"","to":"","reg":false,"x":580,"y":1700,"wires":[["552002a3f019cf7f"]]},{"id":"552002a3f019cf7f","type":"function","z":"9fcb1fec5d4354f5","name":"Generate 1x4 Chart-email friendly","func":"// Input data is expected in msg.payload as an array of objects:\n// [{\"datetime\":..., \"Time\":\"...\", \"CR-1\":..., \"CR-2\":...}, ...]\n\n// --- GLOBAL DYNAMIC INPUTS ---\nconst inputData = msg.payload;\n\n// Global Y-AXIS SCALE CONTROL\nconst globalYMin = msg.min !== undefined ? parseFloat(msg.min) : -20;\nconst globalYMax = msg.max !== undefined ? parseFloat(msg.max) : 50;\n\n// REPORT TITLES\nconst reportMainTitle = 'COLD ROOM TEMPERATURE MONITORING REPORT';\n\n// Define the core configurations for the 4 sensors\nconst sensorData = {\n    \"CR-1\": {\n        title: msg.charttitle1 || \"FG Cold Room-1\",\n        lsl: msg.lsl1 !== undefined ? parseFloat(msg.lsl1) : undefined,\n        usl: msg.usl1 !== undefined ? parseFloat(msg.usl1) : undefined,\n        yMin: msg.min1 !== undefined ? parseFloat(msg.min1) : undefined,\n        yMax: msg.max1 !== undefined ? parseFloat(msg.max1) : undefined,\n        color: { border: '#4CAF50', bg: 'rgba(76, 175, 80, 0.3)', highlight: '#66BB6A' }\n    },\n    \"CR-2\": {\n        title: msg.charttitle2 || \"Frozen Room-1\",\n        lsl: msg.lsl2 !== undefined ? parseFloat(msg.lsl2) : undefined,\n        usl: msg.usl2 !== undefined ? parseFloat(msg.usl2) : undefined,\n        yMin: msg.min2 !== undefined ? parseFloat(msg.min2) : undefined,\n        yMax: msg.max2 !== undefined ? parseFloat(msg.max2) : undefined,\n        color: { border: '#2196F3', bg: 'rgba(33, 150, 243, 0.3)', highlight: '#42A5F5' }\n    },\n    \"CR-3\": {\n        title: msg.charttitle3 || \"RM Cold Room-1\",\n        lsl: msg.lsl3 !== undefined ? parseFloat(msg.lsl3) : undefined,\n        usl: msg.usl3 !== undefined ? parseFloat(msg.usl3) : undefined,\n        yMin: msg.min3 !== undefined ? parseFloat(msg.min3) : undefined,\n        yMax: msg.max3 !== undefined ? parseFloat(msg.max3) : undefined,\n        color: { border: '#FF9800', bg: 'rgba(255, 152, 0, 0.3)', highlight: '#FFA726' }\n    },\n    \"CR-4\": {\n        title: msg.charttitle4 || \"WIP Cold Room-1\",\n        lsl: msg.lsl4 !== undefined ? parseFloat(msg.lsl4) : undefined,\n        usl: msg.usl4 !== undefined ? parseFloat(msg.usl4) : undefined,\n        yMin: msg.min4 !== undefined ? parseFloat(msg.min4) : undefined,\n        yMax: msg.max4 !== undefined ? parseFloat(msg.max4) : undefined,\n        color: { border: '#E91E63', bg: 'rgba(233, 30, 99, 0.3)', highlight: '#EC407A' }\n    }\n};\n\n// Define display orders\nconst metricsDisplayOrder = [\"CR-1\", \"CR-3\", \"CR-2\", \"CR-4\"];\nconst chartDisplayOrder = [\"CR-1\", \"CR-2\", \"CR-3\", \"CR-4\"];\n\nlet overallMinTime = \"N/A\";\nlet overallMaxTime = \"N/A\";\n\n// 1. Process Data\nconst fieldsToProcess = [\"CR-1\", \"CR-2\", \"CR-3\", \"CR-4\"];\nfor (const fieldName of fieldsToProcess) {\n    const config = sensorData[fieldName];\n    const labels = inputData.map(item => item.Time);\n    const temperatures = inputData.map(item => item[fieldName]);\n\n    const finalYMin = config.yMin !== undefined ? config.yMin : globalYMin;\n    const finalYMax = config.yMax !== undefined ? config.yMax : globalYMax;\n\n    let stats = {\n        minTemp: \"N/A\", minTime: \"N/A\", maxTemp: \"N/A\", maxTime: \"N/A\", avg: \"N/A\"\n    };\n\n    if (temperatures.length > 0) {\n        const sum = temperatures.reduce((a, b) => parseFloat(a) + (parseFloat(b) || 0), 0);\n        stats.avg = (sum / temperatures.length).toFixed(1);\n\n        let minVal = Infinity;\n        let maxVal = -Infinity;\n        let minIndex = -1;\n        let maxIndex = -1;\n\n        temperatures.forEach((temp, index) => {\n            const numericalTemp = parseFloat(temp);\n            if (isNaN(numericalTemp)) return;\n\n            if (numericalTemp < minVal) {\n                minVal = numericalTemp;\n                minIndex = index;\n            }\n            if (numericalTemp > maxVal) {\n                maxVal = numericalTemp;\n                maxIndex = index;\n            }\n        });\n\n        if (minIndex !== -1) {\n            stats.minTemp = minVal.toFixed(1);\n            stats.minTime = labels[minIndex];\n        }\n        if (maxIndex !== -1) {\n            stats.maxTemp = maxVal.toFixed(1);\n            stats.maxTime = labels[maxIndex];\n        }\n    }\n\n    if (labels.length > 0) {\n        if (overallMinTime === \"N/A\") overallMinTime = labels[0];\n        overallMaxTime = labels[labels.length - 1];\n    }\n\n    config.labelData = labels;\n    config.tempData = temperatures;\n    config.stats = stats;\n    config.finalYMin = finalYMin;\n    config.finalYMax = finalYMax;\n}\n\n// 2. Build Metrics HTML (1x4 - TIGHT)\nlet metricsHTML = '';\nfor (const fieldName of metricsDisplayOrder) {\n    const config = sensorData[fieldName];\n    const finalYMin = config.finalYMin;\n    const finalYMax = config.finalYMax;\n    const LSL = config.lsl;\n    const USL = config.usl;\n    const displayFinalYMin = finalYMin.toFixed(1);\n    const displayFinalYMax = finalYMax.toFixed(1);\n    const displayLSLUSL = (LSL !== undefined && USL !== undefined)\n        ? `${LSL.toFixed(1)}°C to ${USL.toFixed(1)}°C`\n        : 'N/A';\n\n    metricsHTML += `\n        <td class=\"summary-tile\" style=\"border-left-color: ${config.color.border}; background-color: #252525;\">\n            <div class=\"tile-title\" style=\"color: ${config.color.highlight};\">${config.title} (${fieldName})</div>\n            <div class=\"tile-stat\">\n                <span class=\"stat-label\">Min:</span> <span class=\"stat-value\">${config.stats.minTemp}°C</span>\n                <span class=\"stat-subvalue\">(@ ${config.stats.minTime})</span>\n            </div>\n            <div class=\"tile-stat\">\n                <span class=\"stat-label\">Max:</span> <span class=\"stat-value\">${config.stats.maxTemp}°C</span>\n                <span class=\"stat-subvalue\">(@ ${config.stats.maxTime})</span>\n            </div>\n            <div class=\"tile-stat\">\n                <span class=\"stat-label\">Avg:</span> <span class=\"stat-value\">${config.stats.avg}°C</span>\n                <span class=\"stat-subvalue\">Limit: ${displayLSLUSL} (Y: ${displayFinalYMin}°C to ${displayFinalYMax}°C)</span>\n            </div>\n        </td>\n    `;\n}\n\n// 3. Build FULL WIDTH Charts (ONE PER ROW)\nlet chartsHTML = '';\nconst chartURLs = {};\n\nfor (const fieldName of chartDisplayOrder) {\n    const config = sensorData[fieldName];\n\n    const annotations = [];\n\n    // BRIGHT VISIBLE LSL LINE\n    if (config.lsl !== undefined) {\n        annotations.push({\n            type: 'line',\n            mode: 'horizontal',\n            scaleID: 'y-axis-0',\n            value: config.lsl,\n            borderColor: '#FFD700',\n            borderWidth: 1,\n            borderDash: [10, 5],\n            label: {\n                enabled: true,\n                content: 'LSL: ' + config.lsl.toFixed(1) + '°C',\n                position: 'left',\n                backgroundColor: '#FFD700',\n                fontColor: '#000',\n                fontSize: 11,\n                fontStyle: 'bold',\n                xPadding: 8,\n                yPadding: 5,\n                cornerRadius: 4\n            }\n        });\n    }\n\n    // BRIGHT VISIBLE USL LINE\n    if (config.usl !== undefined) {\n        annotations.push({\n            type: 'line',\n            mode: 'horizontal',\n            scaleID: 'y-axis-0',\n            value: config.usl,\n            borderColor: '#FF4500',\n            borderWidth: 1,\n            borderDash: [10, 5],\n            label: {\n                enabled: true,\n                content: 'USL: ' + config.usl.toFixed(1) + '°C',\n                position: 'right',\n                backgroundColor: '#FF4500',\n                fontColor: '#FFF',\n                fontSize: 11,\n                fontStyle: 'bold',\n                xPadding: 8,\n                yPadding: 5,\n                cornerRadius: 4\n            }\n        });\n    }\n\n    let labels = config.labelData;\n    let data = config.tempData;\n\n    if (labels.length > 150) {\n        const step = Math.ceil(labels.length / 150);\n        labels = labels.filter((_, i) => i % step === 0);\n        data = data.filter((_, i) => i % step === 0);\n    }\n\n    const simplifiedLabels = labels.map(label => {\n        const parts = label.split(' ');\n        if (parts.length > 1) {\n            const timePart = parts[1];\n            const timeComponents = timePart.split(':');\n            return timeComponents.length >= 2 ? `${timeComponents[0]}:${timeComponents[1]}` : timePart;\n        }\n        return label;\n    });\n\n    const chartConfig = {\n        type: 'line',\n        data: {\n            labels: simplifiedLabels,\n            datasets: [{\n                label: config.title,\n                data: data,\n                borderColor: config.color.border,\n                backgroundColor: config.color.bg,\n                borderWidth: 2.5,\n                fill: false,\n                pointRadius: 0,\n                lineTension: 0.1\n            }]\n        },\n        options: {\n            responsive: false,\n            legend: { display: false },\n            title: {\n                display: true,\n                text: config.title + ' (' + fieldName + ')',\n                fontSize: 14,\n                fontStyle: 'bold',\n                fontColor: '#E8E8E8',\n                padding: 10\n            },\n            annotation: {\n                annotations: annotations\n            },\n            scales: {\n                yAxes: [{\n                    id: 'y-axis-0',\n                    ticks: {\n                        fontSize: 11,\n                        fontColor: '#C0C0C0',\n                        beginAtZero: false,\n                        min: config.finalYMin,\n                        max: config.finalYMax,\n                        callback: function (value) {\n                            return value.toFixed(1) + '°';\n                        }\n                    },\n                    scaleLabel: {\n                        display: true,\n                        labelString: 'Temperature (°C)',\n                        fontSize: 12,\n                        fontColor: '#C0C0C0',\n                        fontStyle: 'bold'\n                    },\n                    gridLines: {\n                        color: 'rgba(255,255,255,0.1)',\n                        lineWidth: 1,\n                        drawBorder: true\n                    }\n                }],\n                xAxes: [{\n                    ticks: {\n                        fontSize: 9,\n                        fontColor: '#A0A0A0',\n                        maxRotation: 90,\n                        minRotation: 90,\n                        autoSkip: true,\n                        maxTicksLimit: 25\n                    },\n                    scaleLabel: {\n                        display: true,\n                        labelString: 'Time (HH:MM)',\n                        fontSize: 11,\n                        fontColor: '#C0C0C0'\n                    },\n                    gridLines: {\n                        display: false\n                    }\n                }]\n            },\n            layout: {\n                padding: {\n                    left: 15,\n                    right: 15,\n                    top: 10,\n                    bottom: 10\n                }\n            }\n        }\n    };\n\n    const chartJSON = JSON.stringify(chartConfig);\n    const encodedChart = encodeURIComponent(chartJSON);\n    // FULL WIDTH: 1100x280\n    const chartImageUrl = `https://quickchart.io/chart?bkg=rgb(32,32,32)&devicePixelRatio=1&c=${encodedChart}&width=1100&height=280`;\n\n    chartURLs[fieldName] = chartImageUrl;\n\n    chartsHTML += `\n        <div class=\"chart-container\" style=\"border-left-color: ${config.color.border}; background-color: #252525;\">\n            <img src=\"${chartImageUrl}\" alt=\"${config.title}\" />\n        </div>\n    `;\n}\n\n// 4. Build HTML - FULL WIDTH CHARTS\nconst htmlContent = `\n<!DOCTYPE html>\n<html>\n<head>\n    <title>${reportMainTitle}</title>\n    <meta charset=\"UTF-8\">\n    <style>\n        * { \n            margin: 0; \n            padding: 0; \n            box-sizing: border-box; \n        }\n        body { \n            font-family: Arial, Helvetica, sans-serif;\n            background-color: #1a1a1a !important;\n            color: #E8E8E8; \n            padding: 15px; \n            max-width: 1150px; \n            margin: 0 auto; \n        }\n        .report-title { \n            font-size: 24px; \n            font-weight: bold; \n            color: #4CAF50; \n            text-align: center; \n            margin-bottom: 5px;\n        }\n        .overall-period { \n            font-size: 13px; \n            text-align: center; \n            margin-bottom: 15px; \n            color: #B0B0B0;\n        }\n        .metrics-grid-summary { \n            width: 100%; \n            border-collapse: separate; \n            border-spacing: 8px; \n            margin-bottom: 18px; \n        }\n        .summary-tile { \n            width: 25%; \n            padding: 8px; \n            border-radius: 4px; \n            border-left: 4px solid; \n            vertical-align: top;\n        }\n        .tile-title { \n            font-size: 13px; \n            font-weight: bold; \n            margin-bottom: 3px; \n            padding-bottom: 3px; \n            border-bottom: 1px solid #404040;\n        }\n        .tile-stat { \n            font-size: 11px; \n            line-height: 1.2; \n            margin: 1px 0; \n            padding: 1px 0;\n        }\n        .stat-label { \n            font-weight: bold; \n            color: #999; \n            display: inline-block; \n            width: 34px; \n            font-size: 11px; \n        }\n        .stat-value { \n            font-weight: bold; \n            color: #FFFFFF; \n            margin-right: 3px; \n            font-size: 11px; \n        }\n        .stat-subvalue { \n            font-size: 9px; \n            color: #777; \n            display: block; \n            margin-left: 36px; \n            margin-top: 0px;\n            line-height: 1.1;\n        }\n        .chart-container { \n            background-color: #252525;\n            padding: 6px; \n            border-radius: 4px; \n            border-left: 5px solid; \n            margin-bottom: 12px;\n            text-align: center;\n        }\n        .chart-container img { \n            width: 100%; \n            max-width: 1100px;\n            height: auto; \n            display: block; \n            margin: 0 auto;\n            border-radius: 3px; \n        }\n    </style>\n</head>\n<body bgcolor=\"#1a1a1a\">\n    <div class=\"report-title\">${reportMainTitle}</div>\n    <div class=\"overall-period\">Report Period: ${overallMinTime} to ${overallMaxTime}</div>\n    <table class=\"metrics-grid-summary\"><tr>${metricsHTML}</tr></table>\n    ${chartsHTML}\n</body>\n</html>\n`;\n\n// 5. Build JSON\nconst jsonPayload = {\n    reportTitle: reportMainTitle,\n    reportPeriod: { start: overallMinTime, end: overallMaxTime },\n    sensors: {},\n    chartURLs: chartURLs,\n    generatedAt: new Date().toISOString()\n};\n\nfor (const fieldName of [\"CR-1\", \"CR-2\", \"CR-3\", \"CR-4\"]) {\n    const config = sensorData[fieldName];\n    jsonPayload.sensors[fieldName] = {\n        title: config.title,\n        statistics: {\n            min: { value: config.stats.minTemp, time: config.stats.minTime },\n            max: { value: config.stats.maxTemp, time: config.stats.maxTime },\n            average: config.stats.avg\n        },\n        limits: { lsl: config.lsl, usl: config.usl, yMin: config.finalYMin, yMax: config.finalYMax },\n        dataPoints: config.labelData.length,\n        color: config.color\n    };\n}\n\n// 6. Set outputs\nconst rawDateString = overallMinTime;\nconst dateParts = rawDateString.split(' ');\nconst dayMonth = dateParts.length > 0 ? dateParts[0] : 'Report';\nconst filenameBase = reportMainTitle.replace(/\\s/g, '_');\nconst pathPrefix = \"C:\\\\temp\\\\\";\n\nmsg.payload = htmlContent;\nmsg.filename = `${pathPrefix}${dayMonth}_${filenameBase}.html`;\nmsg.jsonPayload = jsonPayload;\n\nreturn msg;\n","outputs":1,"timeout":0,"noerr":0,"initialize":"","finalize":"","libs":[],"x":800,"y":1700,"wires":[["3f74c0309b626ca7"]]}]
3 Likes

That is standard in Outlook but might also be filtered by your mail server as well. It is too dangerous.

Not checked your flow but if you want reliable imagary in emails, it needs to be fully included in the html. For example, in my professional life I have a British Computer Society logo in my standard email signature. This is data encoded right into the html so no external image is needed since most systems and people sensibly turn OFF external images since those are also very dangerous.

As a further aside, if I want to do anything complex with email in Node-RED, I use an IMAP library directly in a function node. Since the library has a lot of features that the email nodes don't expose.

2 Likes

This flow (specifically the function node) creates a URL for the quickchart.io API, which returns an image file. It works quite fast.

It’s true that mail clients nowadays default to not showing external images, until you click a button. I wouldn’t say external images are ‘very dangerous’ (just like any image in a web page). Except in the very real sense that they are a privacy hazard: the sender can see that you opened a specific email. Every mail client has a button somewhere that says ‘always allow external images from this sender‘, which might be perfectly fine for internal reports such as demonstrated here.

It would also be possible to preload those images and then add them as data-URLs. That would add about 50 kB to the email size, which is not excessive, and prevent the need for the receivers to click any button to see the charts.

1 Like

I would - being involved with some very spice cyber issues over the last few years! There have been a number of fairly high-profile examples of images that have introduced malware without any user intervention.

That is also true of course.

Yes, of course. But including an image as data ensures that your recipients don't have to worry about the privacy and security issues.

Yes, that's what I was suggesting.

If you are suggesting that I create these chart images before hand at source and send them as image data rather than a url to go to an api, that would not be possible as the source computer which runs node-red doesn't have internet connection.
Please let me know if there are other ways around , although as you already noted this is in an internal office network only, so I hope I am safe and I am the author of the node-red flow.

Woud it be possible that you convert your image into base64 and then embedd the result in html? Like described below? You could then use a NR node to convert

Yes, that is the most reliable way.

But I don't see why not having internet at the source device is an issue. If you can attach an image link at the time of sending an email, you can convert that link to a data image at that point. To be able to help more directly, we would need to know more about your flows and image handling.

But you can convert a buffer to base64 encoded and then wrap:

// Read the image file as a buffer (provided by File In node)
const imageBuffer = msg.payload;

// Convert the buffer to base64
const base64Image = imageBuffer.toString('base64');

// Determine the image type (e.g., png, jpg) from the file extension
const ext = msg.filename.split('.').pop();

// Construct the data URI
const dataUri = `data:image/${ext};base64,${base64Image}`;

// Return the data URI
return { payload: dataUri };

I think there may be one or more contributed nodes that will do this for you if you prefer.