背景及心路历程
近期,UPS和邮件通知似乎成了nas圈的一个讨论热点。这引起了我的兴趣,既然已经没电了,那应该也没网,还如何能够实现邮件的及时发送呢?
经过多方了解后主要得到以下的结论:
1、部份UPS自带网络环境可以实现联网;
2、利用UPS给家里的光猫、路由器供电以保证网络连通;
3、白群晖自带邮箱通知服务
同时,我也看到了来自
Shaw0828的文章UPS状态切换邮件通知功能分享
显然,这类方案并不适合我这种没有UPS短时间内也不打算购入UPS的用户。转念一想,事情反而变得简单又清晰了起来。既然没有UPS无法在Nas本地进行状态判断,那就像白群那样整一台永远都不会断 电的服务器,在服务器上进行判断不就好了!
那么,多少价格可以拿下一台永久在线的服务器呢?这让我想到一位大善人!

实现逻辑
客户端:
设定设备名称和Token密钥,采用心跳机制,每间隔一段时间像服务端发送一次请求;
服务端:
- Cloudflare Workers + KV空间
设定需要监控的设备名称、Token密钥、邮件相关参数、执行间隔等内容,并记录该设备的“离线后第一次恢复在线”和“在线后第一次离线”的信息,按设定要求将邮件发送到用户邮箱。由于Workers本身无法发送邮件,只能调用API去实现该功能,因此,我们需要一个支持API发送邮件的平台。
邮件发送平台(免费):
以上平台的注册没有难度,故本文不进行展开,需要注意的是Luckycola的appkey申请可能需要两天,建议提前准备。
配置服务端
第一步:创建KV存储空间
登录cloudflare后,可以在左侧找到“存储和数据库-KV”,如图所示点击Create Instance,并为你的KV创建一个英文名称,点击创建


KV空间创建完成!
第二步:部署Cloudflare Worker作为服务端
Cloudflare worker的免费额度如下:
Workers 使用量:每天10万次请求,最大 10ms CPU 时间/每个请求
Workers KV 使用量:每日 100,000 次读取操作,每日 1,000 次写入、删除、列举
当每日每项使用量超过50%时,Cloudflare会发送邮件提醒。在了解以上限制后,我们可以制作相应的部署!
创建一个Cloudflare Worker
在cloudflare左侧侧边栏中找到“计算(Workers)”进入,按图操作即可,worker名称可行自定义。




完成创建后,点击继续处理项目,来到绑定界面
为新Worker绑定KV空间(数据库)
- 现在,将这个 KV 绑定到您的 Worker:
- 回到您的 Worker (
device-monitor
) 的管理页面。 

- 变量名称为:DEVICE_STATUS KV命名空间选择刚刚创建的,这里我是同名。


点击添加绑定后,会如上图所示↑
部署Cloud flare Worker执行代码
点击此处进入代码编辑页面

参数说明:
代码中HEARTBEAT_TIMEOUT参数用于判定设备是否掉线,当设备超过这个时间没有汇报在线,视为离线并发送邮件通知,建议按网络环境修改,如果心跳发送是两分钟一次,建议改为4分钟以上,以避免由于单次的cloudflare连接不稳定导致误判。
单设备监控
// 心跳超时时间调整为 300 秒 (5分钟)
const HEARTBEAT_TIMEOUT = 300 * 1000;
const COLA_KEY_STORE_KEY = 'global:cola_key_data';
export default {
async fetch(request, env, ctx) {
const url = new URL(request.url);
if (url.pathname !== '/heartbeat' || request.method !== 'POST') {
return new Response('Not Found', { status: 404 });
}
try {
const { token } = await request.json();
if (!token) {
return new Response('Missing token', { status: 400 });
}
const devicename = env.DEVICE_NAME;
const expectedToken = env.DEVICE_TOKEN;
if (!devicename || !expectedToken) {
console.error('Error: DEVICE_NAME or DEVICE_TOKEN environment variable is not set.');
return new Response('Worker is not configured correctly', { status: 500 });
}
if (token !== expectedToken) {
return new Response('Invalid token', { status: 403 });
}
const kvKey = `device:${devicename}`;
const currentStatus = await env.DEVICE_STATUS.get(kvKey, { type: 'json' });
if (currentStatus && currentStatus.status === 'offline') {
const subject = `✅ 设备恢复在线`;
const beijingTime = new Date().toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai' });
const body = `设备 "${devicename}" 已于 ${beijingTime} 恢复在线。`;
ctx.waitUntil(sendEmail(env, subject, body));
}
const newStatus = { lastSeen: Date.now(), status: 'online' };
await env.DEVICE_STATUS.put(kvKey, JSON.stringify(newStatus));
return new Response('Heartbeat received', { status: 200 });
} catch (error) {
console.error('Error processing heartbeat:', error);
return new Response('Internal Server Error', { status: 500 });
}
},
async scheduled(controller, env, ctx) {
console.log('Running scheduled check for the single device...');
const devicename = env.DEVICE_NAME;
if (!devicename) {
console.error('Error: DEVICE_NAME environment variable is not set. Scheduled task cannot run.');
return;
}
const kvKey = `device:${devicename}`;
const now = Date.now();
const status = await env.DEVICE_STATUS.get(kvKey, { type: 'json' });
if (!status || status.status === 'offline') {
return;
}
if (now - status.lastSeen > HEARTBEAT_TIMEOUT) {
console.log(`Device ${devicename} is offline.`);
const newStatus = { ...status, status: 'offline' };
await env.DEVICE_STATUS.put(kvKey, JSON.stringify(newStatus));
const subject = `❌ 设备异常离线`;
const beijingTime = new Date(status.lastSeen).toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai' });
const body = `设备 "${devicename}" 已离线。<br>最后一次心跳时间:${beijingTime}.`;
ctx.waitUntil(sendEmail(env, subject, body));
}
},
};
async function getValidColaKey(env) {
const storedData = await env.DEVICE_STATUS.get(COLA_KEY_STORE_KEY, { type: 'json' });
if (storedData && storedData.key && Date.now() < storedData.endTime) {
console.log('Using valid ColaKey from KV cache.');
return storedData.key;
}
console.log('ColaKey is missing or expired. Fetching a new one...');
try {
const response = await fetch('https://luckycola.com.cn/ai/getColaKey', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ uid: env.UID, appKey: env.APP_KEY }),
});
if (!response.ok) {
throw new Error(`API request failed with status ${response.status}`);
}
const responseData = await response.json();
if (responseData.code === 0 && responseData.data) {
const newData = { key: responseData.data.cola_key, endTime: responseData.data.end_time };
await env.DEVICE_STATUS.put(COLA_KEY_STORE_KEY, JSON.stringify(newData));
console.log('Successfully fetched and stored a new ColaKey.');
return newData.key;
} else {
throw new Error(`API returned an error: ${JSON.stringify(responseData)}`);
}
} catch (error) {
console.error('Failed to fetch a new ColaKey:', error);
return null;
}
}
async function sendEmail(env, subject, body) {
const colaKey = await getValidColaKey(env);
if (!colaKey) {
console.error('Could not obtain a valid ColaKey. Email sending aborted.');
return;
}
const requestBody = {
ColaKey: colaKey,
tomail: env.EMAIL_TO,
fromTitle: env.FROM_TITLE,
subject: subject,
smtpCode: env.SMTP_CODE,
smtpEmail: env.SMTP_EMAIL,
smtpCodeType: env.SMTP_CODE_TYPE,
isTextContent: false,
content: `<div style="font-family: sans-serif;">${body}</div>`,
};
try {
const response = await fetch('https://luckycola.com.cn/tools/customMail', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(requestBody),
});
if (response.ok) {
console.log(`Email sent successfully: "${subject}"`);
} else {
const errorBody = await response.text();
console.error(`Failed to send email. Status: ${response.status}, Body: ${errorBody}`);
}
} catch (error) {
console.error('Error sending email:', error);
}
}
所需创建的环境变量如下:
类型均为纯文本
变量名称:DEVICE_NAME
值:填写客户端名称,自定义项,务必确保与客户端一致,例如“ewedlnt”
变量名称:DEVICE_TOKEN
值:填写客户端密钥,自定义项,务必确保与客户端一致,例如“123123”
变量名称:EMAIL_TO
值:填写目的邮箱(想发到哪个邮箱就写哪个)
变量名称:FROM_TITLE
值:邮件发送标题,自定义项,API发送必要参数
变量名称:UID
值:填写从Luckycola获取到的用户ID
变量名称:APP_KEY
值:填写从Luckycola获取到的APPKEY
变量名称:SMTP_CODE_TYPE
值:填写邮箱类型,当前API仅支持“163/126/qq”三个值,任选其一
变量名称:SMTP_EMAIL
值:填写开启了SMTP服务的邮箱地址
变量名称:SMTP_CODE
值:填写邮件系统授权码
cron表达式:* * * * *
意为每分钟都执行。正常情况下相当于每天执行1440次读取操作,免费套餐完全满足使用
多设备监控
//心跳超时时间调整为 300 秒 (5分钟)
const HEARTBEAT_TIMEOUT = 300 * 1000;
const COLA_KEY_STORE_KEY = 'global:cola_key_data';
export default {
async fetch(request, env, ctx) {
const url = new URL(request.url);
if (url.pathname !== '/heartbeat' || request.method !== 'POST') {
return new Response('Not Found', { status: 404 });
}
try {
const { devicename, token } = await request.json();
if (!devicename || !token) {
return new Response('Missing devicename or token', { status: 400 });
}
const deviceTokens = JSON.parse(env.DEVICE_TOKENS || '{}');
if (deviceTokens[devicename] !== token) {
return new Response('Invalid token', { status: 403 });
}
const kvKey = `device:${devicename}`;
const currentStatus = await env.DEVICE_STATUS.get(kvKey, { type: 'json' });
if (currentStatus && currentStatus.status === 'offline') {
const subject = `✅ 设备恢复在线`;
const beijingTime = new Date().toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai' });
const body = `设备 "${devicename}" 已于 ${beijingTime} 恢复在线。`;
ctx.waitUntil(sendEmail(env, subject, body));
}
const newStatus = { lastSeen: Date.now(), status: 'online' };
await env.DEVICE_STATUS.put(kvKey, JSON.stringify(newStatus));
return new Response('Heartbeat received', { status: 200 });
} catch (error) {
console.error('Error processing heartbeat:', error);
return new Response('Internal Server Error', { status: 500 });
}
},
async scheduled(controller, env, ctx) {
console.log('Running scheduled check for offline devices...');
const list = await env.DEVICE_STATUS.list({ prefix: 'device:' });
const now = Date.now();
for (const key of list.keys) {
const status = await env.DEVICE_STATUS.get(key.name, { type: 'json' });
if (!status || status.status === 'offline') {
continue;
}
if (now - status.lastSeen > HEARTBEAT_TIMEOUT) {
const devicename = key.name.replace('device:', '');
console.log(`Device ${devicename} is offline.`);
const newStatus = { ...status, status: 'offline' };
await env.DEVICE_STATUS.put(key.name, JSON.stringify(newStatus));
const subject = `❌ 设备异常离线`;
const beijingTime = new Date(status.lastSeen).toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai' });
const body = `设备 "${devicename}" 已离线。<br>最后一次心跳时间:${beijingTime}.`;
ctx.waitUntil(sendEmail(env, subject, body));
}
}
},
};
async function getValidColaKey(env) {
const storedData = await env.DEVICE_STATUS.get(COLA_KEY_STORE_KEY, { type: 'json' });
if (storedData && storedData.key && Date.now() < storedData.endTime) {
console.log('Using valid ColaKey from KV cache.');
return storedData.key;
}
console.log('ColaKey is missing or expired. Fetching a new one...');
try {
const response = await fetch('https://luckycola.com.cn/ai/getColaKey', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ uid: env.UID, appKey: env.APP_KEY }),
});
if (!response.ok) {
throw new Error(`API request failed with status ${response.status}`);
}
const responseData = await response.json();
if (responseData.code === 0 && responseData.data) {
const newData = { key: responseData.data.cola_key, endTime: responseData.data.end_time };
await env.DEVICE_STATUS.put(COLA_KEY_STORE_KEY, JSON.stringify(newData));
console.log('Successfully fetched and stored a new ColaKey.');
return newData.key;
} else {
throw new Error(`API returned an error: ${JSON.stringify(responseData)}`);
}
} catch (error) {
console.error('Failed to fetch a new ColaKey:', error);
return null;
}
}
async function sendEmail(env, subject, body) {
const colaKey = await getValidColaKey(env);
if (!colaKey) {
console.error('Could not obtain a valid ColaKey. Email sending aborted.');
return;
}
const requestBody = {
ColaKey: colaKey,
tomail: env.EMAIL_TO,
fromTitle: env.FROM_TITLE,
subject: subject,
smtpCode: env.SMTP_CODE,
smtpEmail: env.SMTP_EMAIL,
smtpCodeType: env.SMTP_CODE_TYPE,
isTextContent: false,
content: `<div style="font-family: sans-serif;">${body}</div>`,
};
try {
const response = await fetch('https://luckycola.com.cn/tools/customMail', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(requestBody),
});
if (response.ok) {
console.log(`Email sent successfully: "${subject}"`);
} else {
const errorBody = await response.text();
console.error(`Failed to send email. Status: ${response.status}, Body: ${errorBody}`);
}
} catch (error) {
console.error('Error sending email:', error);
}
}
所需创建的环境变量如下:
类型均为纯文本
变量名称:DEVICE_TOKENS
值:{"设备名1":"密码1", "设备名2":"密码2"}
例如:{"MyNAS":"nas", "MyPC":"pc"}
变量名称:EMAIL_TO
值:填写目的邮箱(想发到哪个邮箱就写哪个)
变量名称:FROM_TITLE
值:邮件发送标题,自定义项,API发送必要参数
变量名称:UID
值:填写从Luckycola获取到的用户ID
变量名称:APP_KEY
值:填写从Luckycola获取到的APPKEY
变量名称:SMTP_CODE_TYPE
值:填写邮箱类型,当前API仅支持“163/126/qq”三个值,任选其一
变量名称:SMTP_EMAIL
值:填写开启了SMTP服务的邮箱地址
变量名称:SMTP_CODE
值:填写邮件系统授权码
cron表达式:*/3 * * * *
意为每3分钟执行。正常情况下相当于每天执行480次list操作,低于免费列出额度的50%。
根据我们自己的需要,选择代码、环境变量、cron表达式分别填入并点击部署:



关于Cron表达式的填写
至此,位于Cloudflare Worker上的服务端部署完成
配置客户端
Docker
附件:cfheartbeat.zip
(压缩包内仅四个文件,无毒无后门,放心食用)
将以上压缩包文件选定一个位置解压并导入到docker-compose中

在环境变量中按需填写相关内容,构建即可;
MP插件
MP插件已同步上传至github,仅需要在MP项目中插件市场添加如下仓库:
https://github.com/EWEDLCM/MoviePilot-Plugins/
随后找到以下插件,进行安装配置并启用即可


总结
- 本项目仅提供Docker和MP插件两个客户端方案,Docker压缩包中Python脚本实际亦可以运行于青龙面板(在环境变量''处填写响相应内容即可)
- Cloudflare Worker服户端也可以用自有服务器进行部署,有需要可自行利用ai进行制作,客户端无需修改。
- 由于近几年国内的网络环境对Cloudflare的直连一直都不太友好,各地区情况有所不同,在配置时,需要根据实际情况做相应的时间间隔冗余。如果是在无法进行链接或不够稳定,可能需要采取更加科学的上网。
- 文章结束,完结撒花!!