You built a comments system. Users are submitting feedback. But you only find out when you remember to check the D1 console. By then, someone has been waiting days for a response.

This post covers three approaches to getting notified when form submissions arrive: instant emails, daily digests, and webhook-based notifications. Each has trade-offs in complexity, cost, and flexibility.

The Problem

The comments system from my previous post stores submissions in Cloudflare D1. Moderation happens manually via SQL queries in the D1 console. This works, but it requires actively checking for new submissions.

For a low-traffic personal blog, checking once a day might be fine. For a business site or active community, you need to know immediately when someone reaches out.

Option 1: Instant Email via MailChannels

Cloudflare Workers can send emails through MailChannels without any SMTP configuration. MailChannels has a special integration with Cloudflare that allows free email sending from Workers.

How It Works

When a comment is submitted, the Pages Function:

  1. Validates the Turnstile token
  2. Stores the comment in D1
  3. Sends an email notification via MailChannels

Implementation

Add this function to your Pages Function:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
async function sendNotificationEmail(
  to: string,
  subject: string,
  body: string,
  from: string
): Promise<boolean> {
  try {
    const response = await fetch('https://api.mailchannels.net/tx/v1/send', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        personalizations: [{ to: [{ email: to }] }],
        from: { email: from, name: 'Site Notifications' },
        subject: subject,
        content: [{ type: 'text/plain', value: body }],
      }),
    });
    return response.ok;
  } catch (error) {
    console.error('Failed to send email:', error);
    return false;
  }
}

Then call it after storing the comment:

1
2
3
4
5
6
7
// After inserting comment into D1
await sendNotificationEmail(
  '[email protected]',
  `New comment on ${slug}`,
  `Name: ${name}\nEmail: ${email || 'not provided'}\n\nComment:\n${comment}`,
  '[email protected]'
);

DNS Configuration Required

MailChannels requires SPF records to prevent your emails from landing in spam. Add this TXT record to your domain’s DNS:

1
2
3
Type: TXT
Name: _mailchannels
Value: v=mc1 cfid=yourdomain.com

And update your SPF record to include MailChannels:

1
v=spf1 include:_spf.mx.cloudflare.net include:relay.mailchannels.net ~all

Pros and Cons

Pros:

  • Instant notification
  • No external services required
  • Free with Cloudflare Workers
  • Simple implementation

Cons:

  • Requires DNS configuration
  • One email per submission (could flood inbox on high-traffic sites)
  • MailChannels rate limits apply
  • Email deliverability varies

Option 2: Daily Digest via Cron Trigger

Instead of instant emails, send a daily summary of pending submissions. This requires a separate Cloudflare Worker with a Cron Trigger.

How It Works

A scheduled Worker runs at a specified time each day:

  1. Queries D1 for unapproved comments from the last 24 hours
  2. Compiles them into a digest email
  3. Sends a single email with all pending items

Implementation

Create a new Worker file at functions/_scheduled.ts:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
interface Env {
  DB: D1Database;
  NOTIFICATION_EMAIL: string;
  FROM_EMAIL: string;
}

export default {
  async scheduled(event: ScheduledEvent, env: Env, ctx: ExecutionContext) {
    // Query for comments submitted in the last 24 hours that are not approved
    const yesterday = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString();

    const { results: comments } = await env.DB.prepare(`
      SELECT id, slug, name, email, comment, created_at
      FROM comments
      WHERE approved = 0 AND created_at > ?
      ORDER BY created_at DESC
    `).bind(yesterday).all();

    if (comments.length === 0) {
      console.log('No new comments to report');
      return;
    }

    // Build digest email
    let body = `You have ${comments.length} comment(s) awaiting moderation:\n\n`;

    for (const comment of comments) {
      body += `---\n`;
      body += `Post: ${comment.slug}\n`;
      body += `From: ${comment.name}`;
      if (comment.email) body += ` (${comment.email})`;
      body += `\n`;
      body += `Time: ${comment.created_at}\n`;
      body += `Comment: ${comment.comment}\n\n`;
    }

    body += `---\n`;
    body += `Approve comments at: https://dash.cloudflare.com\n`;
    body += `D1 > your-database > Console\n\n`;
    body += `Quick approve:\n`;
    body += `UPDATE comments SET approved = 1 WHERE id IN (${comments.map(c => c.id).join(', ')});\n`;

    // Send via MailChannels
    await fetch('https://api.mailchannels.net/tx/v1/send', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        personalizations: [{ to: [{ email: env.NOTIFICATION_EMAIL }] }],
        from: { email: env.FROM_EMAIL, name: 'Daily Comment Digest' },
        subject: `[${comments.length}] Comments awaiting moderation`,
        content: [{ type: 'text/plain', value: body }],
      }),
    });

    console.log(`Sent digest with ${comments.length} comments`);
  },
};

Configuring the Cron Schedule

Add to your wrangler.toml:

1
2
[triggers]
crons = ["0 9 * * *"]  # Run at 9 AM UTC daily

For Pages Functions, cron triggers work differently. You may need to create a separate Worker and bind it to the same D1 database, or use Cloudflare Queues to trigger the digest.

Alternative: Separate Worker

Create a standalone Worker for the digest:

  1. Create a new Worker in Cloudflare Dashboard
  2. Add the D1 binding pointing to your database
  3. Add environment variables for email addresses
  4. Set the cron trigger schedule

This keeps your Pages Function simple and handles scheduling separately.

Pros and Cons

Pros:

  • Single email per day (no inbox flooding)
  • Batch moderation workflow
  • Includes SQL to approve all at once
  • Runs even if no traffic to site

Cons:

  • More complex setup
  • Delayed notification (up to 24 hours)
  • Requires separate Worker or queue
  • Cron triggers have some limitations on Pages

Option 3: Webhook to External Services

The simplest approach: send a webhook to an external service that handles notifications. No email configuration required.

Using ntfy.sh (Free Push Notifications)

ntfy.sh is a free, open-source push notification service. No account required.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
async function sendPushNotification(
  topic: string,
  title: string,
  message: string
): Promise<void> {
  await fetch(`https://ntfy.sh/${topic}`, {
    method: 'POST',
    headers: {
      'Title': title,
      'Priority': 'default',
    },
    body: message,
  });
}

// After storing comment
await sendPushNotification(
  'your-secret-topic-name',
  `New comment on ${slug}`,
  `From: ${name}\n${comment.substring(0, 200)}`
);

Subscribe to notifications:

  • Mobile: Install ntfy app, subscribe to your topic
  • Desktop: Open https://ntfy.sh/your-secret-topic-name in browser
  • CLI: curl -s ntfy.sh/your-secret-topic-name/json

Using Telegram Bot

Create a Telegram bot and send messages to yourself:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
async function sendTelegramNotification(
  botToken: string,
  chatId: string,
  message: string
): Promise<void> {
  await fetch(`https://api.telegram.org/bot${botToken}/sendMessage`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      chat_id: chatId,
      text: message,
      parse_mode: 'HTML',
    }),
  });
}

// After storing comment
await sendTelegramNotification(
  env.TELEGRAM_BOT_TOKEN,
  env.TELEGRAM_CHAT_ID,
  `<b>New comment on ${slug}</b>\n\nFrom: ${name}\n\n${comment}`
);

To set up:

  1. Message @BotFather on Telegram to create a bot
  2. Get your chat ID by messaging @userinfobot
  3. Add TELEGRAM_BOT_TOKEN and TELEGRAM_CHAT_ID to Pages environment variables

Using Discord Webhook

Send notifications to a Discord channel:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
async function sendDiscordNotification(
  webhookUrl: string,
  content: string
): Promise<void> {
  await fetch(webhookUrl, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ content }),
  });
}

// After storing comment
await sendDiscordNotification(
  env.DISCORD_WEBHOOK_URL,
  `**New comment on ${slug}**\nFrom: ${name}\n\n${comment}`
);

To set up:

  1. In Discord, go to channel settings > Integrations > Webhooks
  2. Create a webhook and copy the URL
  3. Add DISCORD_WEBHOOK_URL to Pages environment variables

Using Zapier or Make

For more complex workflows, use Zapier or Make:

  1. Create a webhook trigger in Zapier/Make
  2. Send POST request from your Pages Function to the webhook URL
  3. Configure Zapier/Make to send email, Slack message, or any other action
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
await fetch(env.ZAPIER_WEBHOOK_URL, {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({
    type: 'new_comment',
    slug,
    name,
    email,
    comment,
    timestamp: new Date().toISOString(),
  }),
});

Pros and Cons

Pros:

  • Simplest implementation
  • No DNS configuration
  • Multiple notification channels
  • Mobile push notifications
  • Free options available

Cons:

  • Depends on external services
  • ntfy.sh topics are public (use random strings)
  • Rate limits vary by service
  • Telegram/Discord require account setup

Which Should You Choose?

Choose Option 1 (Instant Email) if:

  • You want email specifically
  • You control your domain’s DNS
  • Submission volume is low
  • You need to respond quickly

Choose Option 2 (Daily Digest) if:

  • You prefer batch processing
  • Submission volume is moderate to high
  • Instant notification is not critical
  • You want a cleaner inbox

Choose Option 3 (Webhooks) if:

  • You want the simplest setup
  • You prefer push notifications over email
  • You already use Telegram/Discord/Slack
  • You do not control DNS or want to avoid email configuration

Combining Approaches

Nothing stops you from using multiple methods:

1
2
3
4
5
// Instant push for awareness
await sendPushNotification('my-topic', `New comment`, name);

// Email for record-keeping (or daily digest instead)
await sendNotificationEmail(email, subject, body, from);

Or use instant push for comments and daily digest for likes/analytics.

Security Considerations

Whichever approach you choose:

  1. Keep webhook URLs secret - Add them as encrypted environment variables, not in code
  2. Rate limit notifications - Consider batching if traffic spikes
  3. Sanitize content - Do not include raw user input in notification subjects
  4. Use unique topic names - For ntfy.sh, use random strings like UUIDs

Conclusion

Getting notified about form submissions does not require complex infrastructure. MailChannels provides free email sending from Workers. External services like ntfy.sh offer instant push notifications with zero configuration.

For my site, I use ntfy.sh for instant mobile notifications. It took five minutes to set up and requires no DNS changes. When I want more detail, I check the D1 console.

The best solution is the one you will actually use. Pick the simplest option that fits your workflow.