/** * Gmail Notification Gateway * * Sends Hubitat notifications as Gmail messages through a Google Apps Script webhook. * Recipient addresses are held in Apps Script, not on the hub. * * @version 1.2.0 * @author Gordon Thelander * @see https://github.com/GordonThelander/hubitat-gmail-notification-gateway * * Copyright 2026 Gordon Thelander * Licensed under the Apache License, Version 2.0. You may obtain a copy at: * http://www.apache.org/licenses/LICENSE-2.0 * Distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND. * * Change log * 1.2.0 Per-message recipient group using a "Group: family,body" prefix, so one * device can reach every audience. Unknown groups are rejected before * sending, checked against a Known Recipient Groups preference, because * Apps Script's rejection is invisible behind its HTTP 302 redirect. * lastError now reads "none" when clear, since Hubitat does not apply an * empty string state change and a resolved error stayed on screen forever. * 1.1.0 Per-message subject override using a "Subject: subject,body" prefix. * Driver version shown on the device page. importUrl set for one-click updates. * 1.0.0 Initial release. */ import groovy.json.JsonOutput import groovy.json.JsonSlurper // Non-static on purpose. The Hubitat sandbox rejects top-level static methods. String driverVersion() { return "1.2.0" } metadata { definition( name: "Gmail Notification Gateway", namespace: "Hubitat Integrations", author: "Gordon Thelander", importUrl: "https://raw.githubusercontent.com/GordonThelander/hubitat-gmail-notification-gateway/main/Gmail_Notification_Gateway.groovy" ) { capability "Notification" capability "Actuator" capability "Refresh" attribute "lastStatus", "string" attribute "lastSentAt", "string" attribute "lastError", "string" attribute "lastResponse", "string" attribute "lastHttpStatus", "number" attribute "lastWebhookCheck", "string" attribute "driverVersion", "string" command "sendTest" command "checkWebhook" command "clearStatus" } preferences { input name: "webhookUrl", type: "text", title: "Google Apps Script Web App URL", description: "Paste the Apps Script deployment URL ending in /exec", required: true input name: "token", type: "password", title: "Shared Secret Token", description: "Paste the same token used in Apps Script", required: true input name: "recipientGroup", type: "text", title: "Default Recipient Group", description: "Used when the message does not carry its own Group: prefix", defaultValue: "user", required: true input name: "allowGroupPrefix", type: "bool", title: "Allow per-message recipient group", description: "Message text starting with 'Group: family,...' sends that one message to a different group", defaultValue: true input name: "knownGroups", type: "text", title: "Known Recipient Groups", description: "Comma separated list of the groups defined in Apps Script. A Group: prefix naming anything else is rejected before sending, so a typo cannot silently go nowhere. Leave blank to skip the check.", defaultValue: "user,family,critical", required: false input name: "subjectPrefix", type: "text", title: "Email Subject", description: "Used when the message does not carry its own Subject: prefix", defaultValue: "Hubitat Alert", required: true input name: "allowSubjectPrefix", type: "bool", title: "Allow per-message subject override", description: "Message text starting with 'Subject: this is the subject,This is the body' sets the subject for that message only", defaultValue: true input name: "fromName", type: "text", title: "Sender Display Name", defaultValue: "Hubitat", required: true input name: "timeoutSeconds", type: "number", title: "HTTP Timeout Seconds", defaultValue: 30, range: "5..60", required: true input name: "logEnable", type: "bool", title: "Enable debug logging", defaultValue: true } } void installed() { initialiseState() } void updated() { initialiseState() if (logEnable) { log.debug "${device.displayName}: preferences updated" runIn(1800, "logsOff") } } void logsOff() { device.updateSetting("logEnable", [value: "false", type: "bool"]) log.info "${device.displayName}: debug logging disabled" } void refresh() { initialiseState() } void clearStatus() { sendEvent(name: "lastStatus", value: "ready") sendEvent(name: "lastSentAt", value: "") sendEvent(name: "lastError", value: "none") sendEvent(name: "lastResponse", value: "") sendEvent(name: "lastHttpStatus", value: null) sendEvent(name: "lastWebhookCheck", value: "") } void sendTest() { String ts = nowLocal() deviceNotification("Hubitat Gmail notification test from ${device.displayName} at ${ts}") } void checkWebhook() { String url = normaliseUrl(webhookUrl) if (!url) { markFailed("Missing Google Apps Script Web App URL") return } if (!isValidExecUrl(url)) { markFailed("Webhook URL must start with https://script.google.com/macros/s/ and end with /exec") return } Map params = [ uri : url, timeout : safeTimeout(), followRedirects: true, headers : [ "Accept": "application/json" ] ] sendEvent(name: "lastWebhookCheck", value: "checking") sendEvent(name: "lastStatus", value: "checking webhook") sendEvent(name: "lastError", value: "none") sendEvent(name: "lastResponse", value: "") sendEvent(name: "lastHttpStatus", value: null) if (logEnable) { log.debug "${device.displayName}: checking webhook GET: ${redactUrl(url)}" } try { httpGet(params) { resp -> Integer status = safeStatus(resp) String body = safeBody(resp) sendEvent(name: "lastHttpStatus", value: status) sendEvent(name: "lastResponse", value: body.take(255)) if (logEnable) { log.debug "${device.displayName}: GET status=${status}, body=${body.take(500)}" } if (status == 401 || status == 403) { markFailed("Webhook GET rejected HTTP ${status}. Apps Script access must be set to Anyone.") return } if (status < 200 || status >= 400) { markFailed("Webhook GET failed HTTP ${status}: ${body.take(180)}") return } Map parsed = parseJsonOrNull(body) if (parsed?.ok == true) { sendEvent(name: "lastWebhookCheck", value: "ok HTTP ${status}") sendEvent(name: "lastStatus", value: "webhook reachable") sendEvent(name: "lastError", value: "none") sendEvent(name: "lastResponse", value: JsonOutput.toJson(parsed).take(255)) return } if (looksLikeGoogleHtml(body)) { markFailed("Webhook GET returned Google HTML, not JSON. Redeploy Apps Script as Web app: Execute as Me, access Anyone.") return } markFailed("Webhook GET did not return expected JSON ok:true") } } catch (Exception e) { String msg = safeException(e) if (is302Redirect(msg)) { markWebhookReachableViaRedirect() return } markFailed("Webhook GET exception: ${msg}") } } void deviceNotification(String text) { Map routed = splitGroupAndRest(text) Map message = splitSubjectAndBody(routed.rest as String) sendEmailNotification(message.body as String, message.subject as String, routed.group as String) } // Maker API treats commas in a command's secondary value as parameter // separators, so a message containing commas arrives here split across several // arguments and would otherwise not match any method at all. That failure is // silent: Maker API still answers HTTP 200 to the caller. Rejoining is safe // because the split only ever removes the comma itself. Messages using the // "Subject: subject,body" prefix always contain at least one comma, so without // these the documented syntax cannot be used over Maker API. void deviceNotification(Object part1, Object part2) { deviceNotification([part1, part2].join(",")) } void deviceNotification(Object part1, Object part2, Object part3) { deviceNotification([part1, part2, part3].join(",")) } void deviceNotification(Object part1, Object part2, Object part3, Object part4) { deviceNotification([part1, part2, part3, part4].join(",")) } void deviceNotification(Object part1, Object part2, Object part3, Object part4, Object part5) { deviceNotification([part1, part2, part3, part4, part5].join(",")) } private void sendEmailNotification(String text, String subjectOverride = null, String groupOverride = null) { String url = normaliseUrl(webhookUrl) String cleanToken = token?.toString()?.trim() String groupName = (groupOverride ?: recipientGroup)?.toString()?.trim()?.toLowerCase() String cleanText = text?.toString()?.trim() String subject = safeSubject(subjectOverride ?: subjectPrefix) // Checked here rather than left to Apps Script, because Apps Script's // ok:false reply is invisible behind the HTTP 302 (see change log 1.2.0), // so an unknown group would otherwise report "sent" and go nowhere. if (groupOverride && !isKnownGroup(groupName)) { markFailed("Unknown recipient group '${groupName}'. Known groups: ${knownGroupList().join(', ')}") return } if (!url) { markFailed("Missing Google Apps Script Web App URL") return } if (!isValidExecUrl(url)) { markFailed("Webhook URL must be original script.google.com /exec URL") return } if (!cleanToken) { markFailed("Missing shared secret token") return } if (!groupName) { markFailed("Missing recipient group") return } if (!cleanText) { markFailed("Notification text is empty") return } Map payload = [ token : cleanToken, group : groupName, subject : subject, text : cleanText, source : device.displayName, hub : safeHubName(), fromName: safeFromName(fromName) ] Map params = [ uri : url, requestContentType: "application/json", contentType : "application/json", body : JsonOutput.toJson(payload), timeout : safeTimeout(), followRedirects : true, headers : [ "Accept": "application/json" ] ] sendEvent(name: "lastStatus", value: "sending") sendEvent(name: "lastError", value: "none") sendEvent(name: "lastResponse", value: "") sendEvent(name: "lastHttpStatus", value: null) if (logEnable) { log.debug "${device.displayName}: sending Gmail notification" log.debug "${device.displayName}: url=${redactUrl(url)}" log.debug "${device.displayName}: group=${groupName}${groupOverride ? ' (per-message override)' : ''}" log.debug "${device.displayName}: subject=${subject}${subjectOverride ? ' (per-message override)' : ''}" log.debug "${device.displayName}: textLength=${cleanText.length()}" } try { httpPost(params) { resp -> Integer status = safeStatus(resp) String body = safeBody(resp) sendEvent(name: "lastHttpStatus", value: status) sendEvent(name: "lastResponse", value: body.take(255)) if (logEnable) { log.debug "${device.displayName}: POST status=${status}, body=${body.take(500)}" } handlePostResponse(status, body) } } catch (Exception e) { String msg = safeException(e) if (is302Redirect(msg)) { markSentViaRedirect() return } if (msg.contains("401") || msg.toLowerCase().contains("unauthorized")) { markFailed("POST rejected HTTP 401 Unauthorized. Fix Apps Script deployment access: Execute as Me, Who has access Anyone.") return } if (msg.contains("403") || msg.toLowerCase().contains("forbidden")) { markFailed("POST rejected HTTP 403 Forbidden. Fix Apps Script deployment access: Execute as Me, Who has access Anyone.") return } markFailed("POST exception: ${msg}") } } private void handlePostResponse(Integer status, String body) { String cleanBody = body ?: "" if (status == null) { markFailed("No HTTP status returned by gateway") return } if (status == 302) { markSentViaRedirect() return } if (status == 401) { markFailed("POST rejected HTTP 401 Unauthorized. Apps Script access must be Anyone, not Google-account-only.") return } if (status == 403) { markFailed("POST rejected HTTP 403 Forbidden. Apps Script access must be Anyone and Execute as Me.") return } if (status < 200 || status >= 400) { markFailed("HTTP ${status}: ${cleanBody.take(180)}") return } if (!cleanBody.trim()) { markSentTransportOnly(status) return } if (looksLikeGoogleHtml(cleanBody)) { markSentTransportOnly(status, "POST returned Google redirect/HTML after send") return } Map parsed = parseJsonOrNull(cleanBody) if (parsed == null) { markSentTransportOnly(status, "POST returned non-JSON after send") return } if (parsed?.ok == true) { markSent(status, JsonOutput.toJson(parsed)) return } String scriptError = parsed?.error ?: "Apps Script returned ok:false" markFailed("Apps Script error: ${scriptError}".take(255)) } private void markSent(Integer status, String body) { String ts = nowLocal() sendEvent(name: "lastStatus", value: "sent") sendEvent(name: "lastSentAt", value: ts) sendEvent(name: "lastError", value: "none") sendEvent(name: "lastResponse", value: body?.take(255) ?: "HTTP ${status}, sent") sendEvent(name: "lastHttpStatus", value: status) log.info "${device.displayName}: Gmail notification sent at ${ts}" } private void markSentViaRedirect() { String ts = nowLocal() sendEvent(name: "lastStatus", value: "sent") sendEvent(name: "lastSentAt", value: ts) sendEvent(name: "lastError", value: "none") sendEvent(name: "lastResponse", value: "Google Apps Script returned HTTP 302 redirect after POST. Email send treated as successful.") sendEvent(name: "lastHttpStatus", value: 302) log.info "${device.displayName}: Gmail notification treated as sent after Google Apps Script HTTP 302 redirect at ${ts}" } private void markSentTransportOnly(Integer status, String reason = "") { String ts = nowLocal() String response = reason ?: "HTTP ${status}, transport completed after POST" sendEvent(name: "lastStatus", value: "sent") sendEvent(name: "lastSentAt", value: ts) sendEvent(name: "lastError", value: "none") sendEvent(name: "lastResponse", value: response.take(255)) sendEvent(name: "lastHttpStatus", value: status) log.info "${device.displayName}: Gmail notification treated as sent at ${ts}. ${response}" } private void markWebhookReachableViaRedirect() { sendEvent(name: "lastWebhookCheck", value: "ok HTTP 302 redirect") sendEvent(name: "lastStatus", value: "webhook reachable") sendEvent(name: "lastError", value: "none") sendEvent(name: "lastResponse", value: "Google Apps Script returned HTTP 302 redirect during webhook check.") sendEvent(name: "lastHttpStatus", value: 302) log.info "${device.displayName}: webhook reachable via Google Apps Script HTTP 302 redirect" } private void markFailed(String message) { String msg = message?.toString()?.take(255) ?: "Unknown error" sendEvent(name: "lastStatus", value: "failed") sendEvent(name: "lastError", value: msg) log.warn "${device.displayName}: ${msg}" } private void initialiseState() { publishDriverVersion() if (!device.currentValue("lastStatus")) { sendEvent(name: "lastStatus", value: "ready") } if (!device.currentValue("lastError")) { sendEvent(name: "lastError", value: "none") } } // Saving driver code does not call updated(), so this also runs from refresh() // to give a way to resync the displayed version after an update. private void publishDriverVersion() { String v = driverVersion() if (device.currentValue("driverVersion") != v) { sendEvent(name: "driverVersion", value: v) log.info "${device.displayName}: driver version ${v}" } } private Boolean isValidExecUrl(String url) { if (!url) { return false } return url.startsWith("https://script.google.com/macros/s/") && url.endsWith("/exec") } private Boolean is302Redirect(String msg) { if (!msg) { return false } String m = msg.toLowerCase() return m.contains("302") || m.contains("moved temporarily") || m.contains("moved permanently") || m.contains("redirect") } private String normaliseUrl(Object value) { return value?.toString()?.trim() } private Integer safeTimeout() { try { Integer t = (timeoutSeconds ?: 30) as Integer if (t < 5) return 5 if (t > 60) return 60 return t } catch (Exception ignored) { return 30 } } // "Group: family,rest of the message" sends that one message to a different // recipient group. Runs before the Subject: parse, so both can be combined as // "Group: family,Subject: the subject,the body". Anything that does not match // is passed through untouched. private Map splitGroupAndRest(String text) { String raw = text?.toString()?.trim() Map result = [group: null, rest: raw] if (!raw) { return result } if (allowGroupPrefix?.toString() == "false") { return result } if (!raw.toLowerCase().startsWith("group:")) { return result } Integer comma = raw.indexOf(",") if (comma < 0) { return result } String group = raw.substring("group:".length(), comma).trim() if (!group) { return result } String rest = raw.substring(comma + 1).trim() // Nothing left to send. Pass through untouched so the literal text is // visible in the email rather than silently sending an empty message. if (!rest) { return result } result.group = group result.rest = rest return result } private List knownGroupList() { String raw = knownGroups?.toString()?.trim() if (!raw) { return [] } return raw.toLowerCase().split(",").collect { it.trim() }.findAll { it } } private Boolean isKnownGroup(String group) { List known = knownGroupList() // Blank preference means the user opted out of the check. if (!known) { return true } return known.contains(group?.toString()?.trim()?.toLowerCase()) } // Supports the convention used by other Hubitat email notification drivers: // "Subject: this is the subject,This is the body" sets the subject for that // message only. Anything that does not match is passed through untouched. private Map splitSubjectAndBody(String text) { String raw = text?.toString()?.trim() Map result = [subject: null, body: raw] if (!raw) { return result } // String compare, because a bool preference can arrive as the string "false", // which is truthy in Groovy. if (allowSubjectPrefix?.toString() == "false") { return result } if (!raw.toLowerCase().startsWith("subject:")) { return result } Integer comma = raw.indexOf(",") if (comma < 0) { return result } String subject = raw.substring("subject:".length(), comma).trim() if (!subject) { return result } String body = raw.substring(comma + 1).trim() result.subject = subject result.body = body ?: subject return result } private String safeSubject(Object value) { String s = value?.toString()?.trim() if (!s) { s = "Hubitat Alert" } s = s.replaceAll("[\\r\\n]", " ") return s.take(120) } private String safeFromName(Object value) { String s = value?.toString()?.trim() if (!s) { s = "Hubitat" } s = s.replaceAll("[\\r\\n]", " ") return s.take(80) } private String safeHubName() { try { return location?.hub?.name?.toString() ?: "Hubitat" } catch (Exception ignored) { return "Hubitat" } } private String nowLocal() { TimeZone tz = null try { tz = location?.timeZone } catch (Exception ignored) { tz = null } // Fall back to UTC rather than a specific region, so a hub with no timezone // set does not silently stamp times from someone else's part of the world. if (!tz) { tz = TimeZone.getTimeZone("UTC") } return new Date().format("yyyy-MM-dd HH:mm:ss", tz) } private Integer safeStatus(Object resp) { try { if (resp?.hasProperty("status")) { return resp.status as Integer } } catch (Exception ignored) { } try { return resp?.getStatus() as Integer } catch (Exception ignored) { } return null } private String safeBody(Object resp) { try { Object data = resp?.data if (data == null) { return "" } if (data instanceof Map || data instanceof List) { return JsonOutput.toJson(data) } return data.toString() } catch (Exception ignored) { } try { String data = resp?.getData() return data ?: "" } catch (Exception ignored) { } return "" } private Map parseJsonOrNull(String value) { try { if (!value?.trim()) { return null } Object parsed = new JsonSlurper().parseText(value) if (parsed instanceof Map) { return parsed as Map } return null } catch (Exception ignored) { return null } } private Boolean looksLikeGoogleHtml(String body) { if (!body) { return false } String b = body.toLowerCase() return b.contains("