Wanted to share the cloudflare email worker i made to stop using the IMAP integration for constant polling that can be created via the cloudflare dashboard and then set for the catch-all address. Sadly normal routing addresses need to be disabled for it but theres a dictionary in it that handles forwarding/dropping certain addresses.
Cloudflare Email Worker JS Code:
const HASS_URL = 'https://your.internet.accessible.hass.url/';
const HASS_EVENT = 'email';
const HASS_TOKEN = "<https://my.home-assistant.io/redirect/profile_security/>";
const routingRules = {
'*': ['[email protected]'],
'[email protected]': ['[email protected]','[email protected]'],
'[email protected]':[],
};
export default {
/**
* @param {ForwardableEmailMessage} message
* @param {object} env
* @param {object} ctx
*/
async email(message, env, ctx) {
var routedTo = await handleRouting(message, env, ctx, routingRules);
try {
const rawContent = await new Response(message.raw).text();
const decodedBody = decodeEmailBody(rawContent);
const eventData = {
timestamp: new Date().toISOString(),
/*message: message,
env: env,
ctx: ctx,*/
from: message.from,
sender: message.from,
to: message.to,
username: message.to,
reciever: message.to,
routed_to: routedTo,
subject: message.headers.get("subject") || "No Subject",
size: message.rawSize,
content: rawContent, // .replaceAll("\\r\\n","\n")
body: decodedBody,
body_plain: htmlToPlainText(decodedBody),
headers: Array.from(message.headers.entries()).reduce(
(acc, [key, value]) => {
acc[key] = value;
return acc;
},
{}
),
};
const response = await fetch(`${HASS_URL}api/events/${HASS_EVENT}`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/json",
Authorization: `Bearer ${HASS_TOKEN}`, // Using environment variable
},
body: JSON.stringify(eventData),
});
if (!response.ok) {
throw new Error(`Failed to forward email: ${response.statusText}`);
}
console.log("Event sent to Home Assistant");
} catch (error) {
console.error("Error sending HomeAssistant event:", error);
throw error;
}
},
};
/**
* @param {ForwardableEmailMessage} message
* @param {object} env
* @param {object} ctx
* @returns {Promise<string[]>} forwarded-to
*/
async function handleRouting(message, env, ctx, rules) {
const recipient = message.to.toLowerCase();
const rule = rules[recipient];
if (rule) {
// Check if it's an empty array (drop rule)
if (Array.isArray(rule) && rule.length === 0) {
message.setReject("Rejected by routing rule");
console.warn(`Rejected mail from ${message.from} to ${message.to}`);
return [];
}
const forwardTo = Array.isArray(rule) ? rule : [rule];
for (const address of forwardTo) {
await message.forward(address);
}
console.log(
`Forwarded mail from ${message.from} meant for ${
message.to
} to ${forwardTo.join(", ")}`
);
return forwardTo;
}
// Fallback to wildcard rule
if (rules["*"]) {
const forwardTo = Array.isArray(rules["*"]) ? rules["*"] : [rules["*"]];
for (const address of forwardTo) {
await message.forward(address);
}
console.log(
`Forwarded mail from ${message.from} meant for ${
message.to
} to ${forwardTo.join(", ")}`
);
return forwardTo;
}
return [];
}
function splitOnSecondLastDoubleNewline(str) {
const doubleNewline = "\r\n\r\n";
const positions = [];
let index = str.indexOf(doubleNewline);
while (index !== -1) {
positions.push(index);
index = str.indexOf(doubleNewline, index + 1);
}
if (positions.length < 2) {
return [str];
}
const secondLastPos = positions[positions.length - 2];
const before = str.substring(0, secondLastPos);
const after = str.substring(secondLastPos + doubleNewline.length);
return [before, after];
}
/**
* Decode base64-encoded email body if detected
* @param {string} rawContent - The raw email content
* @returns {string} Decoded body content
*/
function decodeEmailBody(rawContent) {
const body = splitOnSecondLastDoubleNewline(rawContent)[1]?.trim() || "";
// Check if the body or a part of it is base64 encoded
// Look for Content-Transfer-Encoding: base64 header followed by content
const base64Pattern =
/Content-Transfer-Encoding:\s*base64\r?\n\r?\n([\s\S]+?)(?:\r?\n--|\r?\n\r?\n--)/gi;
const matches = [...rawContent.matchAll(base64Pattern)];
if (matches.length > 0) {
// Get the last base64 encoded part (usually the HTML content)
const base64Content = matches[matches.length - 1][1]
.trim()
.replace(/\r?\n/g, "");
try {
// Decode base64
const decoded = atob(base64Content);
return decoded;
} catch (error) {
console.warn("Failed to decode base64 content:", error);
return body;
}
}
return body;
}
/**
* Convert HTML to plain text by stripping tags and removing script/style content
* @param {string} html - The HTML content to convert
* @returns {string} Plain text version
*/
function htmlToPlainText(html) {
if (!html) return "";
let text = html;
// Remove all content inside <style*> tags (case insensitive)
text = text.replace(/<style[^>]*>[\s\S]*?<\/style>/gi, "");
// Remove all content inside <script*> tags (case insensitive)
text = text.replace(/<script[^>]*>[\s\S]*?<\/script>/gi, "");
// Strip all remaining HTML tags
text = text.replace(/<[^>]+>/g, "");
// Decode common HTML entities
text = text
.replace(/ /g, " ")
.replace(/&/g, "&")
.replace(/</g, "<")
.replace(/>/g, ">")
.replace(/"/g, '"')
.replace(/'/g, "'")
.replace(/'/g, "'");
// Clean up extra whitespace
text = text.replace(/\s+/g, " ").trim();
return text;
}
Example Automation:
alias: Email Notifications
description: ""
triggers:
- trigger: event
event_type: imap_content
enabled: false
- trigger: event
event_type: email
conditions: []
actions:
- action: notify.blus_notify_devices
metadata: {}
data:
message: |-
{{trigger.event.data.subject}}
{{trigger.event.data.body_plain}}
title: >-
E-Mail from {{trigger.event.data.sender}} to
{{trigger.event.data.username}}
mode: single
