@@ -1511,6 +1511,14 @@ impl Skrepka {
15111511 let Some(peer) = model.active_peer.clone() else {
15121512 return render();
15131513 };
1514+ // Blocking cuts the peer off in both directions (PROTOCOL.md §4). The
1515+ // ingest side drops their messages; the send side must refuse too, or
1516+ // a blocked peer still receives our texts, acks, and the liveness
1517+ // signal that blocking is supposed to cut off.
1518+ if model.contacts.get(&peer).is_some_and(|c| c.blocked) {
1519+ model.error = Some("cannot send to a blocked contact".into());
1520+ return render();
1521+ }
15141522 let body = model.compose.trim().to_string();
15151523 if body.is_empty() {
15161524 return render();
@@ -1768,6 +1776,7 @@ impl Skrepka {
17681776 if model.kv_load_failed {
17691777 return Command::done();
17701778 }
1779+ let prev_cursor = model.cursor;
17711780 if page.cursor > model.cursor {
17721781 model.cursor = page.cursor;
17731782 }
@@ -1797,6 +1806,13 @@ impl Skrepka {
17971806 // A page this large is a misbehaving relay, not real mail.
17981807 // Stop here; the rest of the page is discarded exactly as an
17991808 // undecryptable blob would be.
1809+ //
1810+ // Restore the cursor: it was advanced to `page.cursor` above,
1811+ // but we have not processed the remaining events. If we keep
1812+ // the advanced cursor, the next poll acks the whole page to
1813+ // the relay — including every event we skipped — and the
1814+ // relay deletes them. Those messages are permanently lost.
1815+ model.cursor = prev_cursor;
18001816 model.error = Some("relay sent an oversized poll page".into());
18011817 break;
18021818 }
@@ -4176,4 +4192,86 @@ mod tests {
41764192 "last_ack_ts advances to the fresh ack's ts"
41774193 );
41784194 }
4195+
4196+ // -----------------------------------------------------------------------
4197+ // cursor restoration on budget exhaustion
4198+ // -----------------------------------------------------------------------
4199+
4200+ /// The cursor is advanced to `page.cursor` before the event loop runs. If the
4201+ /// poll-byte budget is exhausted mid-page, the loop breaks — but the cursor
4202+ /// must be restored to its pre-page value. Otherwise the next poll acks the
4203+ /// whole page to the relay, which deletes every event we skipped: permanent
4204+ /// message loss.
4205+ #[test]
4206+ fn a_budget_exhausted_mid_page_restores_the_cursor() {
4207+ let app = Skrepka;
4208+ let mut m = with_identity();
4209+ let peer = peer_hex(9);
4210+ m.contacts
4211+ .insert(peer.clone(), Contact::new(peer.clone(), String::new(), 0));
4212+ m.cursor = 100;
4213+
4214+ // Two blobs, each at the per-blob ceiling (MAX_BLOB_LEN hex chars =
4215+ // MAX_BLOB_LEN/2 decoded bytes = 21 MiB). Individually they pass the
4216+ // per-blob size gate; together they exceed the 64 MiB page budget.
4217+ // The first passes the budget check (21 < 64) and fails decrypt (it's
4218+ // not a real blob); the second fails the budget check (21 + 21 + 21 > 64).
4219+ // Three blobs to be sure: 3 × 21 = 63 MiB (just under 64), so use four.
4220+ let big_hex = "ab".repeat(MAX_BLOB_LEN); // MAX_BLOB_LEN hex chars = MAX_BLOB_LEN/2 bytes
4221+ let page = PollResp {
4222+ events: (0..4)
4223+ .map(|_| PollEvent {
4224+ encrypted_blob: big_hex.clone(),
4225+ })
4226+ .collect(),
4227+ cursor: 200,
4228+ };
4229+ let _ = app.ingest_poll(&mut m, page);
4230+
4231+ assert_eq!(
4232+ m.cursor, 100,
4233+ "the cursor must be restored — the page was not fully processed"
4234+ );
4235+ assert!(
4236+ m.error.as_ref().is_some_and(|e| e.contains("oversized")),
4237+ "the user is told"
4238+ );
4239+ assert!(
4240+ m.messages.get(&peer).is_none_or(Vec::is_empty),
4241+ "nothing was stored from the oversized page"
4242+ );
4243+ }
4244+
4245+ // -----------------------------------------------------------------------
4246+ // send_text block check
4247+ // -----------------------------------------------------------------------
4248+
4249+ /// Blocking cuts a peer off in both directions. `send_text` must refuse to
4250+ /// enqueue a message to a blocked contact — otherwise the block only stops
4251+ /// their incoming mail while we keep sending them ours.
4252+ #[test]
4253+ fn send_text_to_a_blocked_peer_is_refused() {
4254+ let app = Skrepka;
4255+ let mut m = with_identity();
4256+ let peer = peer_hex(9);
4257+ let mut contact = Contact::new(peer.clone(), "Enemy".into(), 0);
4258+ contact.blocked = true;
4259+ m.contacts.insert(peer.clone(), contact);
4260+ m.active_peer = Some(peer.clone());
4261+ m.compose = "hello?".into();
4262+
4263+ let _ = app.update(Event::SendText, &mut m);
4264+
4265+ assert!(
4266+ m.outbox.is_empty(),
4267+ "no message is queued for a blocked peer"
4268+ );
4269+ assert!(
4270+ m.error.as_ref().is_some_and(|e| e.contains("blocked")),
4271+ "the user is told why"
4272+ );
4273+ // The compose buffer is not cleared — the user might want to unblock
4274+ // and resend, and losing their draft would be surprising.
4275+ assert_eq!(m.compose, "hello?");
4276+ }
41794277}