---
title: 'Notification and Email Services'
source: 'https://academia.sh/en/courses/serverless/notification-and-email-services'
course: 'Serverless and Edge Computing'
language: en
updated: '2026-09-13T05:07:39+00:00'
license: 'CC BY-SA 4.0'
objectives:
    - 'Defines the decision handed to the sending service and the reputation-bound quota that comes back in exchange'
    - 'Scans feedback processing rate to measure reputation, throttled days, and delivery count'
    - 'Compares the cost of processing feedback against the delivery loss of not processing it'
---

# Notification and Email Services

The constraint that comes from handing sending to a service: bounced messages and complaints coming back as events, scanning this feedback stream's processing rate to measure the reputation score at day thirty, the number of throttled days, and the number of messages delivered, and comparing the cost of processing feedback against the delivery cost of not processing it.

The gateway brought incoming requests in. The measurement network also has an outward flow it
**sends**: a work notification to the field crew when validation fails, a message to a subscriber
when an invoice is cut, an alert to the branch supervisor on a meter fault. These sends, too, are
handed to a service.

Deliverability itself was measured earlier and that measurement is not repeated here. This
lesson's question is: which constraint does handing sending to a service bring back? The answer
comes from a place unlike any constraint seen so far — **the send's success is not in the
sender's hands.**

## The Handed-Over Decision and What Was Never in Your Hands

What is handed over is familiar: running the sending server, keeping address records, managing
the retry queue, renewing domain verification records. There is one thing that is not handed over,
because it was never in anyone's hands to begin with: the receiving side's decision. A message
gets accepted, bounced, silently dropped into a folder, or marked as a complaint; the party making
that decision is not the sender.

The service measures this decision and reflects it back to the sender as a **reputation**. The
constraint that comes back follows from this: sending quota is bound to reputation. As reputation
falls, quota falls; as quota falls, the number of messages that can be sent falls. In every lesson
so far the constraint was a setting — visibility timeout, maximum deliveries, timeout threshold.
Here the constraint is **an earned number**, and it cannot be tuned directly; it can only be
raised through behavior.

The constraint also has a starting condition. A new sending stream has no history, so it has no
reputation either; the service does not open up volume from the start, it opens it as history
accumulates. The practical result is that sending capacity **cannot scale instantly.** In every
service so far, scale arrived the moment it was demanded — queue depth rose and an instance was
added, request count rose and the gateway kept up. Sending does not work this way: going from
8,000 messages a day to 80,000 is not a technical limit but an unearned permit. If the measurement
network plans to bring a new region online, sending volume has to be ramped up gradually, weeks
ahead of that date.

The way the service communicates this decision is also familiar. Bounces and complaints come back
to the sender as **events**, written to an event bus or a topic just like the events in this
topic's first lesson. A direct consequence follows: if there is no rule to receive those events,
they are dropped without producing any error. The 433 silent drops measured in the first lesson
turn, here, into a list-hygiene problem.

The measurement runs on the fictional regional measurement network's field notifications; the
addresses are fictional names in the form `example.invalid`.

- **ED31.** The list is 12,000 recipients; 480 are invalid from the start (closed account, bad
  domain).
- **ED32.** The daily base quota is 8,000 messages; the actual quota is proportional to
  reputation, and once reputation drops below 60 it is halved on top of that.
- **ED33.** Reputation starts at 100; if the daily bounce rate exceeds 0.02 it drops 6 points, if
  complaint rate exceeds 0.001 it drops 3 points, and it rises 2 points if both are below
  threshold.
- **ED34.** A complaining recipient stays on the list if not removed, and complains again on the
  next send.
- **ED35.** Feedback processing rate is how much of a day's bounce and complaint events actually
  gets reflected onto the list.
- **ED36.** The model builds a single sending stream; content, subject line, and time-of-send
  effects are not measured. There is no randomness in the model.

## The Link Between Processing Rate and Delivery

```js
// reputation.mjs — notification service model: what happens to delivery if feedback goes unprocessed
// Model: not a real send, but the link between feedback processing rate and delivery.
const DAYS = 30, LIST = 12000, BASE = 8000;
const INVALID0 = 480;                          // closed or bad address (fictional list)

function run(p) {                              // p: feedback processing rate
  let invalid = INVALID0, complainers = 0, reputation = 100;
  let delivered = 0, bouncedTotal = 0, complaintsTotal = 0, throttledDays = 0;
  for (let g = 0; g < DAYS; g++) {
    const throttled = reputation < 60;          // sending is throttled once reputation drops below the threshold
    const quota = Math.round(BASE * (reputation / 100) * (throttled ? 0.5 : 1));
    if (throttled) throttledDays++;
    const sent = Math.min(quota, LIST);
    const bounced = Math.round(sent * (invalid / LIST));
    const complaints = Math.round(sent * (0.0006 + complainers / LIST));
    delivered += sent - bounced; bouncedTotal += bounced; complaintsTotal += complaints;
    const bounceRate = bounced / sent, complaintRate = complaints / sent;
    if (bounceRate > 0.02) reputation -= 6;
    if (complaintRate > 0.001) reputation -= 3;
    if (bounceRate < 0.01 && complaintRate < 0.0005) reputation += 2;
    reputation = Math.max(0, Math.min(100, reputation));
    invalid = Math.max(0, invalid - Math.round(bounced * p));      // processed feedback
    complainers = Math.max(0, complainers + Math.round(sent * 0.0006) - Math.round(complaints * p));
  }
  return { reputation, delivered, bouncedTotal, complaintsTotal, throttledDays };
}

console.log("processing rate".padEnd(16) + "day-30 reputation".padStart(19) + "delivered".padStart(11) +
  "bounced".padStart(12) + "complaints".padStart(12) + "throttled days".padStart(16));
for (const p of [0, 0.25, 0.5, 0.9, 1]) {
  const s = run(p);
  console.log(p.toFixed(2).padEnd(16) + String(s.reputation).padStart(19) +
    String(s.delivered).padStart(11) + String(s.bouncedTotal).padStart(12) +
    String(s.complaintsTotal).padStart(12) + String(s.throttledDays).padStart(16));
}
console.log(`days: ${DAYS}, list: ${LIST}, daily base quota: ${BASE}, ` +
  `invalid addresses at start: ${INVALID0}`);
```

```sh
node reputation.mjs
```

```
processing rate   day-30 reputation  delivered     bounced  complaints  throttled days
0.00                              0      40743        1697          65              25
0.25                              7      62990        1450          84              24
0.50                             88     211687         953         207               0
0.90                             94     225548         532         150               0
1.00                             94     225600         480         150               0
days: 30, list: 12000, daily base quota: 8000, invalid addresses at start: 480
```
The first row is the case where feedback is never processed. By day thirty, reputation is
**zero**, sending was throttled on **25 of the thirty days**, and total delivered stayed at
40,743. Nothing broke: the send request was accepted every day, the service returned no error, and
bounce and complaint events were published correctly. Nobody just read them.

The last row is the same list, the same content, the same thirty days, at the opposite end.
Feedback fully processed, reputation stays at 94, no day is throttled, and delivered climbs to
**225,600.** The gap between the two is 184,857 messages — 5.5 times as many.

The bounced column explains the mechanism. With full processing, total bounces are **480** —
exactly the count of invalid addresses in the list: each invalid address bounces once, is
processed, is removed, and is never tried again. Without processing, the same 480 addresses get
retried for thirty days and produce 1,697 bounces. Bounce rate never falls, stays above threshold,
and drags reputation down every single day.

The throttled-days column shows how this decline accelerates. The moment reputation drops below
60, quota is halved on top of the drop; in the first row, 25 of the thirty days are in this state.
Throttling does not just cut delivery — it also thins the feedback stream, so finding the addresses
that need cleaning slows down too. The constraint delays its own recovery.

The complaint column looks backward at first glance: as processing rate rises, complaint count
climbs from 65 to 150. The reason is the denominator — in the unprocessed run, quota has already
collapsed, so very few messages get sent at all. Complaint **rate** falls with processing, its
count rises because volume rises. A gauge looking at raw counts reads this exactly backward.

## Where the Threshold Sits

The real finding sits between the second and third rows. At a processing rate of 0.25, reputation
is 7, delivered is 62,990; at 0.50, reputation is 88, delivered is 211,687. The relationship is not
linear — doubling the processing rate more than triples delivered. The reason is threshold logic:
the moment bounce rate drops below 0.02, the penalty stops and reputation starts rebuilding on its
own. Enough cleanup to cross this threshold is all it takes; everything after that comes for free.

This is why "we process half of our feedback" is not a shortfall, it is a question of which side
of the threshold you land on. Somewhere between 0.25 and 0.50, the system settles into one of two
stable states: either the rate falls below the threshold and delivery climbs to its ceiling, or it
stays above it and quota collapses to the floor. There is no middle ground, because a collapsed
quota also lowers feedback volume, and the addresses that need cleaning get found even slower.

## Processing Feedback Incorrectly

The first measurement showed the benefit of processing, but it assumed a single kind of
processing: a bounced address gets removed from the list. Real feedback is not that uniform. Part
of it is permanent — no such address, no such domain, account closed. Part of it is transient:
mailbox full, receiving server busy, a temporary limit hit. Both often arrive from the same event
stream, in similar-looking shapes.

```js
// bounce-type.mjs — if the feedback type is not distinguished, who gets removed from the list
const DAYS = 30, LIST = 12000, QUOTA = 8000;
const PERMANENT0 = 480;         // permanent error: no such address, no such domain
const TRANSIENT = 0.03;         // transient error: mailbox full, receiving server busy

function run(typed) {
  let valid = LIST - PERMANENT0, invalid = PERMANENT0, wronglyRemoved = 0, delivered = 0;
  for (let g = 0; g < DAYS; g++) {
    const list = valid + invalid;
    const sent = Math.min(QUOTA, list);
    const permanent = Math.round(sent * (invalid / list));
    const transient = Math.round((sent - permanent) * TRANSIENT);
    delivered += sent - permanent - transient;
    invalid = Math.max(0, invalid - permanent);          // permanent errors are removed in both modes
    if (!typed) { valid -= transient; wronglyRemoved += transient; }   // if transient errors are removed too
  }
  return { list: valid + invalid, wronglyRemoved, delivered };
}

console.log("mode".padEnd(24) + "day-30 list".padStart(15) + "wrongly removed".padStart(17) +
  "delivered".padStart(11));
for (const [name, v] of [["no type distinction", false], ["only permanent removed", true]]) {
  const s = run(v);
  console.log(name.padEnd(24) + String(s.list).padStart(15) + String(s.wronglyRemoved).padStart(17) +
    String(s.delivered).padStart(11));
}
console.log(`days: ${DAYS}, starting list: ${LIST}, daily quota: ${QUOTA}`);
```

```sh
node bounce-type.mjs
```

```
mode                        day-30 list  wrongly removed  delivered
no type distinction                5025             6495     210020
only permanent removed            11520                0     232334
days: 30, starting list: 12000, daily quota: 8000
```
Without distinguishing type, **6,495 valid recipients** were removed from the list over thirty
days and the list fell from 12,000 to 5,025. Nothing was wrong with the ones removed; their
mailbox just happened to be full that day. In the second row the list stays at 11,520 — the
starting list with its 480 permanent-invalid addresses removed.

The flaw here runs opposite to the first measurement's: there, feedback was not processed and
reputation dropped; here, it is processed, more than enough, and delivered still drops — 210,020
instead of 232,334. Processing the feedback stream by itself is not enough; the event's **type**
has to be read. The gap sits in two lines of the function that reads the event, and those two
lines cost 6,495 recipients. Getting the line between permanent and transient wrong points the
same way as not processing the stream at all.

## The Cost of Processing, the Price of Not

Processing the feedback stream is one of the cheapest jobs in this topic. In the fully processed
run, thirty days bring in 480 bounce events and 150 complaint events: 630 total. If each event is
processed with a single function call, at the call cost from the previous lesson, thirty days'
cost is 630 × 0.010 = **6.3 fictional units.** Not processing it costs 184,857 undelivered
messages.

Given a ratio this stark, it is common for this stream to go unprocessed, and the reason is
written in the first lesson's measurement. Feedback events are written to a topic; if that topic
has no subscriber, the events drop silently. No error counter rises, no threshold is crossed — a
topic with no subscriber looks like an empty queue, healthy. The symptom only shows up thirty days
later, in a falling delivery count, and by then the cause read out is not the sending service but
something like "the messages are not going through."

How this flow should be built also follows from pieces already measured in this topic. Feedback
events are written to a topic, the topic's subscription is bound to a queue, the queue wakes a
function, and the function writes to the list. The queue's visibility timeout can stay short for
this job, because writing to the list takes only a few units. That the same bounce event can be
delivered twice is the second lesson's result; removing an address from the list is already an
idempotent operation, but if a complaint counter is kept, that counter needs to rest on an
idempotency key just like the fourth lesson's. This is where the topic closes: the sending
service's feedback stream is the single case in this course that uses all five of its lessons at
once.

There are ways around the constraint and each carries its own countable cost. Cleaning the list by
hand substitutes for processing feedback but lags: a weekly cleanup lands in the 0.25-to-0.50 zone
of the table. Splitting sending into two — transactional messages on one stream, bulk
announcements on another — protects one's reputation from the other; its cost is two configuration
surfaces and two feedback streams. Asking to have the quota raised does not remove the constraint
at all: quota is the result of reputation, not its input. This is the strictest constraint counted
in this topic — visibility timeout can be changed, a timeout threshold can be raised, a step limit
can be redrawn; reputation can only be earned.

## Summary

- Once sending is handed to a service, the decision that never comes back is the receiving side's;
  the service reflects that decision back as reputation, and quota is bound to it. This
  constraint is not tuned, it is earned.
- A bounced message and a complaint come back to the sender as events; if there is no rule or
  subscription to receive them, they drop without producing any error.
- With feedback never processed, sending was throttled on 25 of thirty days, reputation fell to
  zero, and delivered stayed at 40,743; fully processed, reputation stayed at 94, zero days were
  throttled, and delivered reached 225,600 — a gap of 184,857 messages.
- With full processing, total bounces are 480, exactly the count of invalid addresses on the
  list; unprocessed, the same 480 addresses produce 1,697 bounces. The relationship is not linear
  either: raising processing rate from 0.25 to 0.50 raises delivered from 62,990 to 211,687,
  because the penalty stops the moment bounce rate drops below threshold.
- Failing to read the event's type does the same damage in reverse: without distinguishing
  permanent from transient failures, 6,495 valid recipients were removed over thirty days and
  delivered fell from 232,334 to 210,020.
- Processing 630 feedback events costs 6.3 fictional units; not processing them costs 184,857
  undelivered messages.

## Next Step

Throughout this topic, functions got linked to each other. Events were routed by rule, queues and
topics stood in as a buffer, multi-step flows were handed to a state machine, clock-triggered jobs
were made idempotent, incoming requests were passed through a gateway, and outbound messages were
tied to a feedback stream. The same account repeated in every case: a decision was handed over, and
a countable constraint came back in exchange.

There is one property common to this whole arrangement, and it has never been asked so far: all of
it runs **in a single region.** The rule engine, the queue, the orchestrator, the scheduler, the
gateway, and the sending service all sit in the same place; a query from the branch screen and a
request from the field app both go to that one place. The only thing close to the user was the
cache — and a cache can only hold an answer already computed. The next topic takes on that limit:
what is gained when the computation itself moves closer to the user, and which runtime constraints
get accepted in exchange.
