Skip to content

feat(newsletter): plaintext channel edit/revoke + reject newsletter on the E2E send path - #725

Merged
jlucaso1 merged 2 commits into
mainfrom
fix/newsletter-jid-reject-e2e-send
Jun 5, 2026
Merged

feat(newsletter): plaintext channel edit/revoke + reject newsletter on the E2E send path#725
jlucaso1 merged 2 commits into
mainfrom
fix/newsletter-jid-reject-e2e-send

Conversation

@jlucaso1

@jlucaso1 jlucaso1 commented Jun 5, 2026

Copy link
Copy Markdown
Collaborator

Makes newsletter (channel) message operations compliant with WhatsApp Web. Channels are plaintext and never use the E2E/Signal path, so this both stops the mis-routing and implements the real plaintext edit/revoke operations.

Problem

pin_message/unpin_message (via send_pin), edit_message, and revoke_message call send_message_impl directly, bypassing the plaintext newsletter branch in send_message_with_options. A newsletter JID therefore fell into the DM/E2E branch and tried to build encrypted device fanout (Signal prekeys) against a channel that never uses Signal.

Changes

  1. Reject guard at the top of send_message_impl: a newsletter JID on the E2E path fails fast with an error pointing at the newsletter methods. Covers every E2E-direct producer (revoke/pin/edit) at the root.

  2. Plaintext newsletter edit/revoke:

    • Client::newsletter().edit_message(jid, message_id, new_content)
    • Client::newsletter().revoke_message(jid, message_id)

    These reference the target by the original message's stanza id (the wire id), exposed on NewsletterMessage.message_id. A single build_newsletter_edit_node keyed off a NewsletterEdit { Edit(&Message), Revoke } enum builds:

    • edit: <message id={message_id} type={content type} edit="3"><plaintext [mediatype]>{new content}</plaintext></message>
    • revoke: <message id={message_id} type="text" edit="8"><plaintext/></message>

    The methods reject non-newsletter JIDs so a misuse cannot send plaintext to a non-channel chat. Pin-in-chat is not a channel operation and stays rejected.

WA Web / whatsmeow cross-reference

  • Edit/revoke key on the message's stanza id, NOT server_id: WA Web mergeNewsletterClientIDMixin sets smax("message", { id: STANZA_ID(messageId) }) only; server_id appears only on the reaction/pollVote path (ClientAndServerIDMixin). whatsmeow sendNewsletter sets attrs["id"] = req.ID where req.ID = protocolMessage.key.id (the original message's stanza id string), and emits no server_id.
  • AdminEdit = "3", AdminRevoke = "8", ContentTypeText -> type="text". whatsmeow EditAttributeAdminEdit/Revoke match.

Tests

  • E2E reject guard: newsletter_jid_rejected_on_e2e_send_path, pin_message_rejects_newsletter.
  • Builder: build_newsletter_edit_node_emits_plaintext_edit (text edit, id string, no mediatype, content round-trip), build_newsletter_edit_node_media_edit (type="media" + mediatype="image"), build_newsletter_edit_node_revoke_is_empty_plaintext.
  • Public API: newsletter_edit_message_wrapper_sends_plaintext_edit (end-to-end via the sent-node intercept), newsletter_edit_revoke_reject_non_newsletter_jid.
  • cargo fmt, cargo clippy --all-targets -- -D warnings, and the whatsapp-rust + wacore suites are green.

Breaking

None on stable surface. The new NewsletterMessage.message_id field is additive.

Newsletters are plaintext channels: their only valid send is the <plaintext>
branch in send_message_with_options, which returns before send_message_impl. But
pin_message/unpin_message (via send_pin), edit_message, and revoke_message call
send_message_impl directly with no newsletter guard, so a newsletter JID fell
into the DM/E2E branch and tried to build encrypted device fanout (Signal
prekeys) against a channel that never uses Signal, producing an invalid stanza
or a prekey-fetch error.

Add a single is_newsletter() guard at the top of send_message_impl so every
E2E-direct producer fails fast with a clear error instead of mis-routing. WA Web
does support newsletter edit/revoke as plaintext operations; emitting those
plaintext stanzas is left as a follow-up. Pin-in-chat is not a channel
operation, so rejecting it is correct. Resolves the mis-routing for pin/unpin,
edit, and revoke at the root.
@coderabbitai

coderabbitai Bot commented Jun 5, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: ef575cd2-d26e-43de-b9a0-f30b9568e996

📥 Commits

Reviewing files that changed from the base of the PR and between ce464fe and ec5a804.

📒 Files selected for processing (2)
  • src/features/newsletter.rs
  • src/send.rs

📝 Walkthrough

Summary by CodeRabbit

  • New Features

    • Added newsletter edit and revoke operations for channel messages, including message-id keys for targeting edits/revokes.
  • Bug Fixes

    • Rejects misrouted pin/edit/revoke attempts on non-newsletter targets earlier with clearer newsletter-related errors.
  • Tests

    • Expanded tests for newsletter rejection behavior, edit/revoke stanza construction (plaintext, media, revokes), API wrappers, and validation of message-id handling.

Walkthrough

Newsletter plaintext edit/revoke node builder and Newsletter API methods were added, NewsletterMessage now records stanza id as message_id, and send_message_impl now rejects newsletter JIDs early; tests validate builder output, wrapper behavior, and rejection handling.

Changes

Newsletter edit/revoke and send-path guard

Layer / File(s) Summary
Newsletter edit/revoke node builder and unit tests
src/send.rs
Adds NewsletterEdit and build_newsletter_edit_node that constructs plaintext <message> edit/revoke stanzas (encoded plaintext for edits, empty plaintext for revoke) and unit tests validating decoding and media mediatype.
E2E send guard for newsletter JIDs and tests
src/send.rs
Adds an early to.is_newsletter() guard in send_message_impl that returns an error referencing "newsletter" to prevent pin/edit/revoke callers from entering the E2E encrypted fanout path; tests assert rejection via direct call and through pin_message.
Newsletter API methods, parsing, and wrapper tests
src/features/newsletter.rs
Adds Newsletter::edit_message and Newsletter::revoke_message which validate newsletter JIDs and non-empty message_id, call build_newsletter_edit_node(...) with the stanza message_id, send via self.client.send_node(node).await?, and updates parsing to populate NewsletterMessage.message_id; wrapper tests verify emitted stanza and validation errors.

Sequence Diagram(s)

sequenceDiagram
  participant NewsletterAPI as Newsletter::edit_message/revoke_message
  participant Builder as build_newsletter_edit_node
  participant Client as Client::send_node
  participant Server as XMPP/WhatsAppServer
  NewsletterAPI->>Builder: build_newsletter_edit_node(to, message_id, Edit/Revoke)
  Builder->>Client: return <message> Node (to,id,type,edit,plaintext)
  Client->>Server: send_node(<message> Node)
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

Suggested labels: api-design

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed Title directly summarizes the main changes: newsletter plaintext edit/revoke support and E2E path rejection for newsletters.
Description check ✅ Passed Description thoroughly explains the problem, changes, and technical rationale with WhatsApp Web alignment and test coverage.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/newsletter-jid-reject-e2e-send

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@github-actions

github-actions Bot commented Jun 5, 2026

Copy link
Copy Markdown

Benchmark Results

67 unchanged benchmark(s)
Benchmark Current Baseline Change
reporting_token_benchmark::content_extraction_group::bench_content_extraction simple:setup_simple_message() 2,838 2,838 +0.0%
reporting_token_benchmark::content_extraction_group::bench_content_extraction extended:setup_extended_message() 8,272 8,272 +0.0%
reporting_token_benchmark::key_derivation_group::bench_key_derivation 31,317 31,317 +0.0%
reporting_token_benchmark::token_calculation_group::bench_token_calculation 13,827 13,827 +0.0%
reporting_token_benchmark::full_generation_group::bench_full_token_generation simple:setup_full_gen_simple() 49,398 49,398 +0.0%
reporting_token_benchmark::full_generation_group::bench_full_token_generation extended:setup_full_gen_extended() 54,827 54,827 +0.0%
reporting_token_benchmark::message_encoding_group::bench_message_encoding simple:setup_simple_message() 1,592 1,592 +0.0%
reporting_token_benchmark::message_encoding_group::bench_message_encoding extended:setup_extended_message() 4,219 4,219 +0.0%
send_receive_benchmark::dm_send::bench_dm_send text:setup_dm_send() 112,827 112,823 +0.0%
send_receive_benchmark::dm_recv::bench_dm_recv text:setup_dm_recv() 1,656,386 1,656,388 -0.0%
send_receive_benchmark::group_send::bench_group_send group_10:setup_group_send_10() 650,299 650,050 +0.0%
send_receive_benchmark::group_send::bench_group_send group_50:setup_group_send_50() 874,328 874,100 +0.0%
send_receive_benchmark::group_send::bench_group_send group_256:setup_group_send_256() 2,081,936 2,081,993 -0.0%
send_receive_benchmark::group_send_skdm::bench_group_send_skdm skdm_10:setup_group_skdm_10() 747,536 747,516 +0.0%
send_receive_benchmark::group_send_skdm::bench_group_send_skdm skdm_50:setup_group_skdm_50() 1,328,854 1,328,821 +0.0%
send_receive_benchmark::group_send_skdm::bench_group_send_skdm skdm_256:setup_group_skdm_256() 4,377,285 4,374,824 +0.1%
send_receive_benchmark::group_recv::bench_group_recv text:setup_group_recv() 515,194 518,701 -0.7%
binary_benchmark::marshal_group::bench_marshal_allocating 45,395 45,395 +0.0%
binary_benchmark::marshal_group::bench_marshal_auto_allocating 45,445 45,445 +0.0%
binary_benchmark::marshal_group::bench_marshal_exact_allocating 66,348 66,348 +0.0%
binary_benchmark::marshal_group::bench_marshal_reusing_buffer 43,506 43,506 +0.0%
binary_benchmark::marshal_group::bench_marshal_reusing_buffer_vec_writer 45,501 45,501 +0.0%
binary_benchmark::marshal_group::bench_marshal_long_string 4,936 4,936 +0.0%
binary_benchmark::marshal_group::bench_marshal_auto_long_string 4,967 4,967 +0.0%
binary_benchmark::marshal_group::bench_marshal_exact_long_string 6,738 6,738 +0.0%
binary_benchmark::marshal_group::bench_marshal_huge_bytes_allocating 528,539 528,539 +0.0%
binary_benchmark::marshal_group::bench_marshal_auto_huge_bytes_allocating 528,152 528,152 +0.0%
binary_benchmark::marshal_group::bench_marshal_exact_huge_bytes_allocating 529,398 529,398 +0.0%
binary_benchmark::marshal_group::bench_marshal_many_children_allocating 5,417,742 5,417,742 +0.0%
binary_benchmark::marshal_group::bench_marshal_auto_many_children_allocating 5,362,091 5,362,091 +0.0%
binary_benchmark::marshal_group::bench_marshal_exact_many_children_allocating 13,276,430 13,276,430 +0.0%
binary_benchmark::unmarshal_group::bench_unmarshal small:setup_small_marshaled() 1,850 1,850 +0.0%
binary_benchmark::unmarshal_group::bench_unmarshal large:setup_large_marshaled() 29,217 29,217 +0.0%
binary_benchmark::unpack_group::bench_unpack_uncompressed 618 618 +0.0%
binary_benchmark::unpack_group::bench_unpack_compressed 672,890 672,890 +0.0%
binary_benchmark::attr_parser_group::bench_attr_parser attr_lookup:setup_attr_marshaled() 3,736 3,736 +0.0%
binary_benchmark::roundtrip_group::bench_roundtrip small:setup_small_marshaled() 3,840 3,840 +0.0%
binary_benchmark::roundtrip_group::bench_roundtrip large:setup_large_marshaled() 48,274 48,274 +0.0%
binary_benchmark::roundtrip_group::bench_roundtrip_auto small:setup_small_marshaled() 3,866 3,866 +0.0%
binary_benchmark::roundtrip_group::bench_roundtrip_auto large:setup_large_marshaled() 48,335 48,335 +0.0%
binary_benchmark::roundtrip_group::bench_roundtrip_exact small:setup_small_marshaled() 5,206 5,206 +0.0%
binary_benchmark::roundtrip_group::bench_roundtrip_exact large:setup_large_marshaled() 66,659 66,659 +0.0%
binary_benchmark::child_iteration_group::bench_get_children_by_tag 310,312 310,312 +0.0%
binary_benchmark::jid_optimization_group::bench_jid_to_owned_access jid_access:setup_jid_heavy_marshaled() 8,282 8,282 +0.0%
numeric_attr_benchmark::bench_group::bench_baseline_u32 254 254 +0.0%
numeric_attr_benchmark::bench_group::bench_proposed_u32 91 91 +0.0%
numeric_attr_benchmark::bench_group::bench_baseline_u64 292 292 +0.0%
numeric_attr_benchmark::bench_group::bench_proposed_u64 137 137 +0.0%
numeric_attr_benchmark::bench_group::bench_baseline_i64 317 317 +0.0%
numeric_attr_benchmark::bench_group::bench_proposed_i64 145 145 +0.0%
numeric_attr_benchmark::bench_group::bench_baseline_loop_100_u64 27,425 27,425 +0.0%
numeric_attr_benchmark::bench_group::bench_proposed_loop_100_u64 10,725 10,725 +0.0%
libsignal_benchmark::dm_group::bench_dm_session_establishment setup:setup_dm_users() 4,142,600 4,140,826 +0.0%
libsignal_benchmark::dm_group::bench_dm_encrypt_first_message first_msg:setup_dm_session() 100,133 100,131 +0.0%
libsignal_benchmark::dm_group::bench_dm_decrypt_first_message decrypt_prekey:setup_dm_with_first_message() 4,264,189 4,264,189 +0.0%
libsignal_benchmark::dm_group::bench_dm_encrypt_subsequent_message subsequent:setup_established_dm_session() 100,399 100,399 +0.0%
libsignal_benchmark::group_messaging_group::bench_group_create_distribution_message create:setup_group_sender() 210,262 210,262 +0.0%
libsignal_benchmark::group_messaging_group::bench_group_encrypt_message encrypt:setup_group_with_distribution() 496,921 496,921 +0.0%
libsignal_benchmark::group_messaging_group::bench_group_decrypt_message decrypt:setup_group_with_encrypted_message() 505,954 510,241 -0.8%
libsignal_benchmark::conversation_group::bench_full_dm_conversation full:setup_conversation_data() 11,975,678 11,973,673 +0.0%
libsignal_benchmark::signature_group::bench_signature_creation sign:setup_keypair_with_message() 2,466,138 2,466,138 +0.0%
libsignal_benchmark::signature_group::bench_signature_verification verify:setup_keypair_with_message() 4,921,022 4,907,972 +0.3%
libsignal_benchmark::signature_group::bench_key_generation keygen 2,043,397 2,043,397 +0.0%
libsignal_benchmark::session_optimization_group::bench_decrypt_with_previous_session previous_session:setup_with_archived_sessions() 37,414 37,414 +0.0%
libsignal_benchmark::session_optimization_group::bench_out_of_order_decryption out_of_order:setup_out_of_order_messages() 3,617,967 3,617,967 +0.0%
libsignal_benchmark::session_optimization_group::bench_promote_matching_session promote:setup_promote_matching_session() 230,648 230,648 +0.0%
libsignal_benchmark::session_optimization_group::bench_message_key_eviction eviction:setup_message_key_eviction() 9,980,959 9,980,959 +0.0%
No significant changes detected.

@jlucaso1 jlucaso1 changed the title fix(send): reject newsletter JIDs on the E2E send path feat(newsletter): plaintext channel edit/revoke + reject newsletter on the E2E send path Jun 5, 2026

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: f5cdddc07b

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/send.rs
@jlucaso1
jlucaso1 force-pushed the fix/newsletter-jid-reject-e2e-send branch from f5cdddc to ce464fe Compare June 5, 2026 04:58

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/features/newsletter.rs (1)

618-622: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Parse edit before falling back to type.

This is going to misclassify the wire shape we just added. build_newsletter_edit_node() emits edits as type="text|media" edit="3" and revokes as type="text" edit="8", but this parser only reads type, so history fetches will come back as Text/Media instead of Edit/Revoke. That makes NewsletterMessageType::Edit/Revoke unreachable for messages produced by the new API.

Suggested fix
-        let message_type = msg_node
-            .get_attr("type")
-            .map(|v| v.as_str())
-            .map(|s| NewsletterMessageType::from(s.as_ref()))
-            .unwrap_or(NewsletterMessageType::Text);
+        let message_type = match msg_node.get_attr("edit").map(|v| v.as_str().into_owned()) {
+            Some(edit) if edit == "3" => NewsletterMessageType::Edit,
+            Some(edit) if edit == "8" => NewsletterMessageType::Revoke,
+            _ => msg_node
+                .get_attr("type")
+                .map(|v| v.as_str())
+                .map(|s| NewsletterMessageType::from(s.as_ref()))
+                .unwrap_or(NewsletterMessageType::Text),
+        };
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/features/newsletter.rs` around lines 618 - 622, The parser currently
assigns message_type from msg_node.get_attr("type") which ignores the new
edit/revoke wire shape; change the logic to first inspect
msg_node.get_attr("edit") and map known edit codes (e.g. "3" ->
NewsletterMessageType::Edit, "8" -> NewsletterMessageType::Revoke) to produce
Edit/Revoke, and only if no edit attribute is present fall back to the existing
type-based mapping (the current NewsletterMessageType::from path). Update the
code where message_type is computed (the msg_node / NewsletterMessageType usage
shown) so edit takes precedence over type while preserving the existing
Text/Media fallback behavior.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/features/newsletter.rs`:
- Around line 394-434: Both edit_message and revoke_message should reject an
empty message_id at the public API boundary: check the message_id (the id
variable produced from message_id.into()) for empty string and return an
Err(anyhow::anyhow!(...)) with a clear error when it is empty (mentioning
NewsletterMessage.message_id may be empty) before calling
crate::send::build_newsletter_edit_node or self.client.send_node; update both
functions (edit_message and revoke_message) to perform this validation and
short-circuit on empty id.

---

Outside diff comments:
In `@src/features/newsletter.rs`:
- Around line 618-622: The parser currently assigns message_type from
msg_node.get_attr("type") which ignores the new edit/revoke wire shape; change
the logic to first inspect msg_node.get_attr("edit") and map known edit codes
(e.g. "3" -> NewsletterMessageType::Edit, "8" -> NewsletterMessageType::Revoke)
to produce Edit/Revoke, and only if no edit attribute is present fall back to
the existing type-based mapping (the current NewsletterMessageType::from path).
Update the code where message_type is computed (the msg_node /
NewsletterMessageType usage shown) so edit takes precedence over type while
preserving the existing Text/Media fallback behavior.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: bc7560d0-d14e-4e6c-9b37-ebc8b5f265b0

📥 Commits

Reviewing files that changed from the base of the PR and between f5cdddc and ce464fe.

📒 Files selected for processing (2)
  • src/features/newsletter.rs
  • src/send.rs

Comment thread src/features/newsletter.rs
Newsletter (channel) edit and revoke are real WhatsApp Web plaintext operations
(OutMessagePublishNewsletterEditMixin / RevokeMixin), not E2E. Add
Client::newsletter().edit_message(jid, message_id, new_content) and
revoke_message(jid, message_id).

Both reference the target by the original message's stanza id (the wire `id`),
matching WA Web (mergeNewsletterClientIDMixin sets only `id`) and whatsmeow
(sendNewsletter, req.ID = protocolMessage.key.id), NOT by server_id (that attr is
reaction-only). NewsletterMessage now exposes the wire `id` (message_id) so
callers can pass it. One builder (build_newsletter_edit_node) keyed off a
NewsletterEdit { Edit(&Message), Revoke } enum so invalid edit/revoke+content
combinations are unrepresentable:
  edit:   <message id={message_id} type={content} edit="3"><plaintext [mediatype]>{new content}</plaintext></message>
  revoke: <message id={message_id} type="text" edit="8"><plaintext/></message>

The methods reject non-newsletter JIDs so a misuse cannot send plaintext to a
non-channel chat. Pin-in-chat is not a channel op and stays rejected.
@jlucaso1
jlucaso1 force-pushed the fix/newsletter-jid-reject-e2e-send branch from ce464fe to ec5a804 Compare June 5, 2026 05:08
@jlucaso1
jlucaso1 merged commit e5da892 into main Jun 5, 2026
11 checks passed
@jlucaso1
jlucaso1 deleted the fix/newsletter-jid-reject-e2e-send branch June 5, 2026 05:15
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant