Skip to content

feat: newsletter (channel) support — full CRUD, messaging, reactions, live updates - #390

Merged
jlucaso1 merged 8 commits into
mainfrom
feat/newsletter-phase1
Mar 18, 2026
Merged

feat: newsletter (channel) support — full CRUD, messaging, reactions, live updates#390
jlucaso1 merged 8 commits into
mainfrom
feat/newsletter-phase1

Conversation

@jlucaso1

@jlucaso1 jlucaso1 commented Mar 18, 2026

Copy link
Copy Markdown
Collaborator

Summary

Full newsletter (channel) feature implementation. WhatsApp calls these "Channels" in the UI but "newsletter" in the protocol layer.

API

// Metadata & management
client.newsletter().create(name, desc)
client.newsletter().join(&jid)
client.newsletter().leave(&jid)
client.newsletter().update(&jid, name, desc)
client.newsletter().list_subscribed()
client.newsletter().get_metadata(&jid)
client.newsletter().get_metadata_by_invite(code)

// Messages
client.newsletter().send_message(&jid, &message)
client.newsletter().get_messages(&jid, count, before)  // with pagination
client.newsletter().send_reaction(&jid, server_id, "👍")

// Live updates
client.newsletter().subscribe_live_updates(&jid)
// → Event::NewsletterLiveUpdate dispatched on reaction changes

New types

  • NewsletterMetadata — full metadata (name, description, subscribers, verification, invite code, etc.)
  • NewsletterMessage — history entry with decoded protobuf + reaction counts
  • NewsletterReactionCount — emoji + count
  • NewsletterVerification, NewsletterState, NewsletterRole
  • NewsletterLiveUpdate, NewsletterLiveUpdateMessage, NewsletterLiveUpdateReaction (events)
  • Jid::newsletter(id) factory method

New files

  • wacore/src/iq/newsletter.rs — Mex doc ID constants for all newsletter GraphQL operations
  • src/features/newsletter.rs — Feature handle with 11 methods + types
  • src/handlers/notification.rs — Newsletter notification handler (type="newsletter")
  • wacore/src/types/events.rsEvent::NewsletterLiveUpdate variant
  • tests/e2e/tests/newsletter.rs — 9 E2E tests

E2E tests (9/9 passing)

Test Covers
test_newsletter_create_and_list Create → verify in list_subscribed
test_newsletter_get_metadata Fetch by JID + fetch by invite code
test_newsletter_join Client A creates, Client B joins
test_newsletter_leave Create → leave
test_newsletter_update Update name/desc → verify via metadata fetch
test_newsletter_send_and_get_messages Send text → fetch history → verify decoded protobuf
test_newsletter_message_pagination Send 5 messages → paginate with before cursor
test_newsletter_subscribe_live_updates Subscribe → verify duration returned
test_newsletter_reaction_live_update Send message → react → verify Event::NewsletterLiveUpdate with reaction counts

Not covered (can be added later)

These are niche admin-only features. Mex doc IDs are already defined in wacore/src/iq/newsletter.rs:

  • fetch_admin_count — get admin count for a newsletter
  • fetch_admin_capabilities — get admin permissions
  • fetch_pending_invites — get pending admin invitations
  • fetch_subscribers — get full subscriber list (admin only)
  • fetch_reaction_senders — get who reacted with each emoji
  • Newsletter picture upload (requires media upload integration)
  • Newsletter deletion/suspension

Test plan

  • cargo test --all --exclude e2e-tests — all unit tests pass
  • cargo clippy --all --tests — zero warnings
  • cargo test -p e2e-tests --test newsletter — 9/9 passing
  • Verify no behavioral change in connected session

Summary by CodeRabbit

  • New Features
    • Full newsletter support: create, list, join, leave, update, subscribe to live updates, send messages, send reactions, and fetch paginated message history.
  • Events
    • Emits newsletter live-update events with per-message reaction counts.
  • Public API
    • New newsletter accessor, types, and a JID factory for newsletter identifiers are publicly exposed.
  • Tests
    • Added end-to-end tests covering create/list/metadata/join/leave/update/messages/pagination/live updates and reactions.

@coderabbitai

coderabbitai Bot commented Mar 18, 2026

Copy link
Copy Markdown

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Adds a new Newsletter feature: a crate-private newsletter module with data models and client handle, IQ/MEX IDs and namespace, JID factory for newsletters, notification handling for live updates, public re-exports, and end-to-end tests.

Changes

Cohort / File(s) Summary
Feature module & exports
src/features/newsletter.rs, src/features/mod.rs, src/lib.rs
Add newsletter module, enums/structs (Newsletter, NewsletterMessage, NewsletterMetadata, NewsletterReactionCount, NewsletterRole/State/Verification), Newsletter<'a> API methods, parsing helpers, and re-export types from crate root.
wacore: IQ docs & namespace
wacore/src/iq/newsletter.rs, wacore/src/iq/mod.rs
Add iq::newsletter module, MEX doc ID LEAVE, and NEWSLETTER_XMLNS constant.
wacore: JID factory
wacore/binary/src/jid.rs
Add Jid::newsletter(id: impl Into<String>) -> Self constructor.
Event handling & notifications
src/handlers/notification.rs, wacore/src/types/events.rs
Handle notification type "newsletter"; add NewsletterLiveUpdate event and related message/reaction types; emit Event::NewsletterLiveUpdate with parsed reactions.
End-to-end tests
tests/e2e/tests/newsletter.rs
Add Tokio-based E2E tests covering newsletter lifecycle: create/list/get/join/leave/update/messages/reactions/live-subscribe and pagination.

Sequence Diagram

sequenceDiagram
    participant Client as Client
    participant Handle as NewsletterHandle
    participant Mex as Mex/IQ Layer
    participant Parser as Parser
    participant EventSys as Event Dispatcher

    Client->>Handle: newsletter().get_metadata(jid)
    Handle->>Mex: send MEX GraphQL request (fetch metadata)
    Mex-->>Handle: JSON/MEX response
    Handle->>Parser: parse_newsletter_metadata(response)
    Parser-->>Handle: NewsletterMetadata
    Handle-->>Client: Result<NewsletterMetadata>

    Mex->>EventSys: push IQ notification (newsletter live update)
    EventSys->>Parser: parse live_updates/messages/reactions
    Parser-->>EventSys: NewsletterLiveUpdate
    EventSys-->>Client: emit Event::NewsletterLiveUpdate
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

Poem

🐰 I hopped into bytes and stitched a feed,

JIDs and docs to help each reader heed,
Messages, reactions, live updates in flight,
I nibble bugs and code through day and night,
A cheerful rabbit cheers this newsletter sight!

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately and specifically summarizes the main feature addition: newsletter support with CRUD operations, messaging, reactions, and live updates.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.

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

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/newsletter-phase1
📝 Coding Plan
  • Generate coding plan for human review comments

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 Mar 18, 2026

Copy link
Copy Markdown

🐰 Bencher Report

Branchfeat/newsletter-phase1
Testbedubuntu-latest
Click to view all benchmark results
BenchmarkInstructionsBenchmark Result
instructions
(Result Δ%)
Upper Boundary
instructions
(Limit %)
binary_benchmark::attr_parser_group::bench_attr_parser attr_lookup:setup_attr_marshaled()📈 view plot
🚷 view threshold
6,197.00
(-7.76%)Baseline: 6,718.64
7,054.58
(87.84%)
binary_benchmark::child_iteration_group::bench_get_children_by_tag📈 view plot
🚷 view threshold
524,304.00
(-37.41%)Baseline: 837,636.51
879,518.33
(59.61%)
binary_benchmark::jid_optimization_group::bench_jid_to_owned_access jid_access:setup_jid_heavy_marshaled()📈 view plot
🚷 view threshold
20,868.00
(-8.84%)Baseline: 22,891.40
24,035.97
(86.82%)
binary_benchmark::marshal_group::bench_marshal_allocating📈 view plot
🚷 view threshold
98,193.00
(-22.74%)Baseline: 127,086.82
133,441.16
(73.59%)
binary_benchmark::marshal_group::bench_marshal_auto_allocating📈 view plot
🚷 view threshold
98,221.00
(-16.87%)Baseline: 118,148.18
124,055.59
(79.17%)
binary_benchmark::marshal_group::bench_marshal_auto_huge_bytes_allocating📈 view plot
🚷 view threshold
532,948.00
(-0.19%)Baseline: 533,985.66
560,684.94
(95.05%)
binary_benchmark::marshal_group::bench_marshal_auto_long_string📈 view plot
🚷 view threshold
15,862.00
(-8.23%)Baseline: 17,283.59
18,147.76
(87.40%)
binary_benchmark::marshal_group::bench_marshal_auto_many_children_allocating📈 view plot
🚷 view threshold
14,713,533.00
(-13.53%)Baseline: 17,016,454.04
17,867,276.74
(82.35%)
binary_benchmark::marshal_group::bench_marshal_exact_allocating📈 view plot
🚷 view threshold
118,349.00
(-31.84%)Baseline: 173,631.00
182,312.55
(64.92%)
binary_benchmark::marshal_group::bench_marshal_exact_huge_bytes_allocating📈 view plot
🚷 view threshold
534,378.00
(-0.19%)Baseline: 535,399.21
562,169.17
(95.06%)
binary_benchmark::marshal_group::bench_marshal_exact_long_string📈 view plot
🚷 view threshold
17,911.00
(-7.37%)Baseline: 19,336.73
20,303.57
(88.22%)
binary_benchmark::marshal_group::bench_marshal_exact_many_children_allocating📈 view plot
🚷 view threshold
28,063,047.00
(-33.19%)Baseline: 42,006,118.63
44,106,424.56
(63.63%)
binary_benchmark::marshal_group::bench_marshal_huge_bytes_allocating📈 view plot
🚷 view threshold
533,387.00
(-0.19%)Baseline: 534,424.66
561,145.89
(95.05%)
binary_benchmark::marshal_group::bench_marshal_long_string📈 view plot
🚷 view threshold
15,835.00
(-11.30%)Baseline: 17,853.06
18,745.71
(84.47%)
binary_benchmark::marshal_group::bench_marshal_many_children_allocating📈 view plot
🚷 view threshold
14,714,965.00
(-13.53%)Baseline: 17,017,331.01
17,868,197.56
(82.35%)
binary_benchmark::marshal_group::bench_marshal_reusing_buffer📈 view plot
🚷 view threshold
108,094.00
(-18.21%)Baseline: 132,167.05
138,775.40
(77.89%)
binary_benchmark::marshal_group::bench_marshal_reusing_buffer_vec_writer📈 view plot
🚷 view threshold
98,293.00
(-16.86%)Baseline: 118,220.18
124,131.19
(79.18%)
binary_benchmark::roundtrip_group::bench_roundtrip large:setup_large_marshaled()📈 view plot
🚷 view threshold
90,974.00
(-7.80%)Baseline: 98,674.29
103,608.00
(87.81%)
binary_benchmark::roundtrip_group::bench_roundtrip small:setup_small_marshaled()📈 view plot
🚷 view threshold
7,378.00
(-5.07%)Baseline: 7,772.10
8,160.70
(90.41%)
binary_benchmark::roundtrip_group::bench_roundtrip_auto large:setup_large_marshaled()📈 view plot
🚷 view threshold
91,005.00
(-3.18%)Baseline: 93,995.23
98,695.00
(92.21%)
binary_benchmark::roundtrip_group::bench_roundtrip_auto small:setup_small_marshaled()📈 view plot
🚷 view threshold
7,401.00
(+0.65%)Baseline: 7,352.97
7,720.62
(95.86%)
binary_benchmark::roundtrip_group::bench_roundtrip_exact large:setup_large_marshaled()📈 view plot
🚷 view threshold
106,790.00
(-2.72%)Baseline: 109,780.23
115,269.25
(92.64%)
binary_benchmark::roundtrip_group::bench_roundtrip_exact small:setup_small_marshaled()📈 view plot
🚷 view threshold
8,913.00
(+0.54%)Baseline: 8,864.97
9,308.22
(95.75%)
binary_benchmark::unmarshal_group::bench_unmarshal large:setup_large_marshaled()📈 view plot
🚷 view threshold
41,989.00
(-11.03%)Baseline: 47,195.25
49,555.02
(84.73%)
binary_benchmark::unmarshal_group::bench_unmarshal small:setup_small_marshaled()📈 view plot
🚷 view threshold
2,717.00
(-6.26%)Baseline: 2,898.32
3,043.23
(89.28%)
binary_benchmark::unpack_group::bench_unpack_compressed📈 view plot
🚷 view threshold
556,092.00
(+3.62%)Baseline: 536,680.11
563,514.12
(98.68%)
binary_benchmark::unpack_group::bench_unpack_uncompressed📈 view plot
🚷 view threshold
771.00
(-0.44%)Baseline: 774.38
813.10
(94.82%)
libsignal_benchmark::conversation_group::bench_full_dm_conversation full:setup_conversation_data()📈 view plot
🚷 view threshold
27,761,214.00
(+0.17%)Baseline: 27,714,408.67
29,100,129.10
(95.40%)
libsignal_benchmark::dm_group::bench_dm_decrypt_first_message decrypt_prekey:setup_dm_with_first_message()📈 view plot
🚷 view threshold
5,540,322.00
(-0.17%)Baseline: 5,549,721.48
5,827,207.55
(95.08%)
libsignal_benchmark::dm_group::bench_dm_encrypt_first_message first_msg:setup_dm_session()📈 view plot
🚷 view threshold
178,094.00
(+0.03%)Baseline: 178,044.01
186,946.21
(95.26%)
libsignal_benchmark::dm_group::bench_dm_encrypt_subsequent_message subsequent:setup_established_dm_session()📈 view plot
🚷 view threshold
178,905.00
(+0.03%)Baseline: 178,855.33
187,798.10
(95.26%)
libsignal_benchmark::dm_group::bench_dm_session_establishment setup:setup_dm_users()📈 view plot
🚷 view threshold
17,196,261.00
(-0.51%)Baseline: 17,285,243.56
18,149,505.74
(94.75%)
libsignal_benchmark::group_messaging_group::bench_group_create_distribution_message create:setup_group_sender()📈 view plot
🚷 view threshold
295,884.00
(+0.01%)Baseline: 295,846.73
310,639.06
(95.25%)
libsignal_benchmark::group_messaging_group::bench_group_decrypt_message decrypt:setup_group_with_encrypted_message()📈 view plot
🚷 view threshold
12,610,346.00
(+0.11%)Baseline: 12,596,554.84
13,226,382.58
(95.34%)
libsignal_benchmark::group_messaging_group::bench_group_encrypt_message encrypt:setup_group_with_distribution()📈 view plot
🚷 view threshold
715,609.00
(-0.01%)Baseline: 715,704.97
751,490.22
(95.23%)
libsignal_benchmark::session_optimization_group::bench_decrypt_with_previous_session previous_session:setup_with_archived_sessions()📈 view plot
🚷 view threshold
41,823.00
(+0.03%)Baseline: 41,811.97
43,902.57
(95.26%)
libsignal_benchmark::session_optimization_group::bench_message_key_eviction eviction:setup_message_key_eviction()📈 view plot
🚷 view threshold
15,561,842.00
(+0.00%)Baseline: 15,561,715.65
16,339,801.43
(95.24%)
libsignal_benchmark::session_optimization_group::bench_out_of_order_decryption out_of_order:setup_out_of_order_messages()📈 view plot
🚷 view threshold
5,504,859.00
(-0.11%)Baseline: 5,510,915.24
5,786,461.01
(95.13%)
libsignal_benchmark::session_optimization_group::bench_promote_matching_session promote:setup_promote_matching_session()📈 view plot
🚷 view threshold
956,774.00
(-0.22%)Baseline: 958,850.21
1,006,792.72
(95.03%)
libsignal_benchmark::signature_group::bench_key_generation keygen📈 view plot
🚷 view threshold
2,822,723.00
(-0.01%)Baseline: 2,822,873.57
2,964,017.25
(95.23%)
libsignal_benchmark::signature_group::bench_signature_creation sign:setup_keypair_with_message()📈 view plot
🚷 view threshold
3,444,364.00
(-1.09%)Baseline: 3,482,219.88
3,656,330.88
(94.20%)
libsignal_benchmark::signature_group::bench_signature_verification verify:setup_keypair_with_message()📈 view plot
🚷 view threshold
125,427,305.00
(+0.03%)Baseline: 125,395,555.87
131,665,333.66
(95.26%)
reporting_token_benchmark::content_extraction_group::bench_content_extraction extended:setup_extended_message()📈 view plot
🚷 view threshold
11,812.00
(+0.12%)Baseline: 11,798.08
12,387.98
(95.35%)
reporting_token_benchmark::content_extraction_group::bench_content_extraction simple:setup_simple_message()📈 view plot
🚷 view threshold
3,825.00
(+0.04%)Baseline: 3,823.36
4,014.52
(95.28%)
reporting_token_benchmark::full_generation_group::bench_full_token_generation extended:setup_full_gen_extended()📈 view plot
🚷 view threshold
87,720.00
(-0.26%)Baseline: 87,949.46
92,346.94
(94.99%)
reporting_token_benchmark::full_generation_group::bench_full_token_generation simple:setup_full_gen_simple()📈 view plot
🚷 view threshold
79,754.00
(-0.30%)Baseline: 79,997.57
83,997.45
(94.95%)
reporting_token_benchmark::key_derivation_group::bench_key_derivation📈 view plot
🚷 view threshold
51,011.00
(-0.02%)Baseline: 51,020.76
53,571.79
(95.22%)
reporting_token_benchmark::message_encoding_group::bench_message_encoding extended:setup_extended_message()📈 view plot
🚷 view threshold
5,762.00
(+0.26%)Baseline: 5,746.97
6,034.32
(95.49%)
reporting_token_benchmark::message_encoding_group::bench_message_encoding simple:setup_simple_message()📈 view plot
🚷 view threshold
2,122.00
(+0.18%)Baseline: 2,118.09
2,224.00
(95.41%)
reporting_token_benchmark::token_calculation_group::bench_token_calculation📈 view plot
🚷 view threshold
21,920.00
(+0.02%)Baseline: 21,915.72
23,011.51
(95.26%)
🐰 View full continuous benchmarking report in Bencher

@jlucaso1 jlucaso1 changed the title feat: newsletter (channel) Phase 1 — core types and read-only queries feat: newsletter (channel) support — create, join, metadata, messages, reactions Mar 18, 2026

@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: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/features/newsletter.rs`:
- Around line 176-196: The current parsing silently maps missing/unknown fields
into defaults (name -> "", description -> None, subscriber_count -> 0,
verification -> NewsletterVerification::Unverified, state ->
NewsletterState::Active) which hides malformed payloads; update the
deserialization in the parsing block that handles thread and value so it fails
fast instead: validate presence and correct types for thread["name"]["text"],
thread["description"]["text"] (if required), thread["subscribers_count"],
thread["verification"], and value["state"]["type"], returning/propagating an
error (or Result::Err) when a required field is missing or contains an unknown
enum variant instead of mapping to empty/zero/defaults; specifically change the
code that sets name, description, subscriber_count, verification, and state to
use explicit checks (and map_str_to_enum logic) that return an error for unknown
verification -> NewsletterVerification and unknown state -> NewsletterState
rather than defaulting to Unverified/Active.

In `@tests/e2e/tests/newsletter.rs`:
- Around line 4-63: These two E2E tests (test_list_subscribed_newsletters and
test_get_newsletter_metadata) must be gated until the mock server supports
newsletter queries: modify the tests to early-skip instead of running against
the shared MOCK_SERVER_URL — either add a test-level gate (e.g., #[ignore] or a
cfg feature) or perform a runtime check after TestClient::connect(...) (check an
env var like NEWSLETTER_MOCK_ENABLED or a helper such as
client.mock_supports_newsletter()) and return Ok(()) when the mock lacks
newsletter support; update both test_list_subscribed_newsletters and
test_get_newsletter_metadata to use the chosen gate so they no longer run
against the unsupported mock server.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: f04c3b6e-59e7-4a3b-b117-66eee2841c62

📥 Commits

Reviewing files that changed from the base of the PR and between 54d3c8d and 09f6d10.

📒 Files selected for processing (7)
  • src/features/mod.rs
  • src/features/newsletter.rs
  • src/lib.rs
  • tests/e2e/tests/newsletter.rs
  • wacore/binary/src/jid.rs
  • wacore/src/iq/mod.rs
  • wacore/src/iq/newsletter.rs

Comment on lines +176 to +196
let name = thread["name"]["text"].as_str().unwrap_or("").to_string();
let description = thread["description"]["text"]
.as_str()
.filter(|s| !s.is_empty())
.map(|s| s.to_string());

let subscriber_count = thread["subscribers_count"]
.as_str()
.and_then(|s| s.parse::<u64>().ok())
.unwrap_or(0);

let verification = match thread["verification"].as_str() {
Some("VERIFIED") => NewsletterVerification::Verified,
_ => NewsletterVerification::Unverified,
};

let state = match value["state"]["type"].as_str() {
Some("suspended") => NewsletterState::Suspended,
Some("geosuspended") => NewsletterState::Geosuspended,
_ => NewsletterState::Active,
};

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Fail fast instead of manufacturing “valid” newsletter metadata from malformed payloads.

On Line 176, Line 182, and Line 192, missing or unknown server fields are collapsed into "", 0, Unverified, and Active. That hides schema drift and can incorrectly surface a suspended newsletter as active instead of failing the request.

🛠️ Example hardening
-    let name = thread["name"]["text"].as_str().unwrap_or("").to_string();
+    let name = thread["name"]["text"]
+        .as_str()
+        .filter(|s| !s.is_empty())
+        .ok_or_else(|| MexError::PayloadParsing("missing newsletter name".into()))?
+        .to_string();
@@
-    let subscriber_count = thread["subscribers_count"]
-        .as_str()
-        .and_then(|s| s.parse::<u64>().ok())
-        .unwrap_or(0);
+    let subscriber_count = thread["subscribers_count"]
+        .as_u64()
+        .or_else(|| thread["subscribers_count"].as_str().and_then(|s| s.parse::<u64>().ok()))
+        .ok_or_else(|| {
+            MexError::PayloadParsing("missing newsletter subscriber count".into())
+        })?;

Do the same for verification / state instead of defaulting unknown variants to a real state.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/features/newsletter.rs` around lines 176 - 196, The current parsing
silently maps missing/unknown fields into defaults (name -> "", description ->
None, subscriber_count -> 0, verification -> NewsletterVerification::Unverified,
state -> NewsletterState::Active) which hides malformed payloads; update the
deserialization in the parsing block that handles thread and value so it fails
fast instead: validate presence and correct types for thread["name"]["text"],
thread["description"]["text"] (if required), thread["subscribers_count"],
thread["verification"], and value["state"]["type"], returning/propagating an
error (or Result::Err) when a required field is missing or contains an unknown
enum variant instead of mapping to empty/zero/defaults; specifically change the
code that sets name, description, subscriber_count, verification, and state to
use explicit checks (and map_str_to_enum logic) that return an error for unknown
verification -> NewsletterVerification and unknown state -> NewsletterState
rather than defaulting to Unverified/Active.

Comment on lines +4 to +63
#[tokio::test]
async fn test_list_subscribed_newsletters() -> anyhow::Result<()> {
let _ = env_logger::builder().is_test(true).try_init();

let client = TestClient::connect("e2e_newsletter_list").await?;

let newsletters = client.client.newsletter().list_subscribed().await?;
// Mock server should have at least one newsletter
assert!(
!newsletters.is_empty(),
"should have subscribed newsletters"
);

let first = &newsletters[0];
assert!(!first.name.is_empty(), "newsletter should have a name");
assert!(
!first.jid.user.is_empty(),
"newsletter should have a JID user"
);

info!(
"Listed {} subscribed newsletters, first: {} ({})",
newsletters.len(),
first.name,
first.jid
);

client.disconnect().await;

Ok(())
}

#[tokio::test]
async fn test_get_newsletter_metadata() -> anyhow::Result<()> {
let _ = env_logger::builder().is_test(true).try_init();

let client = TestClient::connect("e2e_newsletter_meta").await?;

// First list to get a known newsletter JID
let newsletters = client.client.newsletter().list_subscribed().await?;
assert!(!newsletters.is_empty(), "need at least one newsletter");

let jid = &newsletters[0].jid;
let metadata = client.client.newsletter().get_metadata(jid).await?;

assert_eq!(metadata.jid, *jid);
assert!(!metadata.name.is_empty(), "newsletter should have a name");
assert!(
metadata.subscriber_count > 0,
"newsletter should have subscribers"
);

info!(
"Fetched metadata for {}: name={}, subscribers={}",
metadata.jid, metadata.name, metadata.subscriber_count
);

client.disconnect().await;

Ok(())

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Gate these E2E tests until the mock server implements newsletter queries.

On Line 8 and Line 40, the different TestClient::connect(...) prefixes only change local test isolation; both tests still point at the same MOCK_SERVER_URL. Since this PR explicitly calls out mock-server newsletter support as pending, these tests will fail for harness reasons as soon as the e2e suite starts running them.

🧪 Suggested fix
-#[tokio::test]
+#[tokio::test]
+#[ignore = "enable once the mock server supports newsletter GraphQL queries"]
 async fn test_list_subscribed_newsletters() -> anyhow::Result<()> {
@@
-#[tokio::test]
+#[tokio::test]
+#[ignore = "enable once the mock server supports newsletter GraphQL queries"]
 async fn test_get_newsletter_metadata() -> anyhow::Result<()> {
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
#[tokio::test]
async fn test_list_subscribed_newsletters() -> anyhow::Result<()> {
let _ = env_logger::builder().is_test(true).try_init();
let client = TestClient::connect("e2e_newsletter_list").await?;
let newsletters = client.client.newsletter().list_subscribed().await?;
// Mock server should have at least one newsletter
assert!(
!newsletters.is_empty(),
"should have subscribed newsletters"
);
let first = &newsletters[0];
assert!(!first.name.is_empty(), "newsletter should have a name");
assert!(
!first.jid.user.is_empty(),
"newsletter should have a JID user"
);
info!(
"Listed {} subscribed newsletters, first: {} ({})",
newsletters.len(),
first.name,
first.jid
);
client.disconnect().await;
Ok(())
}
#[tokio::test]
async fn test_get_newsletter_metadata() -> anyhow::Result<()> {
let _ = env_logger::builder().is_test(true).try_init();
let client = TestClient::connect("e2e_newsletter_meta").await?;
// First list to get a known newsletter JID
let newsletters = client.client.newsletter().list_subscribed().await?;
assert!(!newsletters.is_empty(), "need at least one newsletter");
let jid = &newsletters[0].jid;
let metadata = client.client.newsletter().get_metadata(jid).await?;
assert_eq!(metadata.jid, *jid);
assert!(!metadata.name.is_empty(), "newsletter should have a name");
assert!(
metadata.subscriber_count > 0,
"newsletter should have subscribers"
);
info!(
"Fetched metadata for {}: name={}, subscribers={}",
metadata.jid, metadata.name, metadata.subscriber_count
);
client.disconnect().await;
Ok(())
#[tokio::test]
#[ignore = "enable once the mock server supports newsletter GraphQL queries"]
async fn test_list_subscribed_newsletters() -> anyhow::Result<()> {
let _ = env_logger::builder().is_test(true).try_init();
let client = TestClient::connect("e2e_newsletter_list").await?;
let newsletters = client.client.newsletter().list_subscribed().await?;
// Mock server should have at least one newsletter
assert!(
!newsletters.is_empty(),
"should have subscribed newsletters"
);
let first = &newsletters[0];
assert!(!first.name.is_empty(), "newsletter should have a name");
assert!(
!first.jid.user.is_empty(),
"newsletter should have a JID user"
);
info!(
"Listed {} subscribed newsletters, first: {} ({})",
newsletters.len(),
first.name,
first.jid
);
client.disconnect().await;
Ok(())
}
#[tokio::test]
#[ignore = "enable once the mock server supports newsletter GraphQL queries"]
async fn test_get_newsletter_metadata() -> anyhow::Result<()> {
let _ = env_logger::builder().is_test(true).try_init();
let client = TestClient::connect("e2e_newsletter_meta").await?;
// First list to get a known newsletter JID
let newsletters = client.client.newsletter().list_subscribed().await?;
assert!(!newsletters.is_empty(), "need at least one newsletter");
let jid = &newsletters[0].jid;
let metadata = client.client.newsletter().get_metadata(jid).await?;
assert_eq!(metadata.jid, *jid);
assert!(!metadata.name.is_empty(), "newsletter should have a name");
assert!(
metadata.subscriber_count > 0,
"newsletter should have subscribers"
);
info!(
"Fetched metadata for {}: name={}, subscribers={}",
metadata.jid, metadata.name, metadata.subscriber_count
);
client.disconnect().await;
Ok(())
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/e2e/tests/newsletter.rs` around lines 4 - 63, These two E2E tests
(test_list_subscribed_newsletters and test_get_newsletter_metadata) must be
gated until the mock server supports newsletter queries: modify the tests to
early-skip instead of running against the shared MOCK_SERVER_URL — either add a
test-level gate (e.g., #[ignore] or a cfg feature) or perform a runtime check
after TestClient::connect(...) (check an env var like NEWSLETTER_MOCK_ENABLED or
a helper such as client.mock_supports_newsletter()) and return Ok(()) when the
mock lacks newsletter support; update both test_list_subscribed_newsletters and
test_get_newsletter_metadata to use the chosen gate so they no longer run
against the unsupported mock server.

@jlucaso1 jlucaso1 changed the title feat: newsletter (channel) support — create, join, metadata, messages, reactions feat: newsletter (channel) support — full CRUD, messaging, reactions, live updates Mar 18, 2026
@jlucaso1

Copy link
Copy Markdown
Collaborator Author

@claude review

@jlucaso1

Copy link
Copy Markdown
Collaborator Author

@codex review

@jlucaso1

Copy link
Copy Markdown
Collaborator Author

@coderabbitai review

@claude

claude Bot commented Mar 18, 2026

Copy link
Copy Markdown

Claude finished @jlucaso1's task in 1m 24s —— View job


Code Review: Newsletter Feature PR

  • Read core feature implementation (src/features/newsletter.rs)
  • Read notification handler (src/handlers/notification.rs)
  • Read E2E tests (tests/e2e/tests/newsletter.rs)
  • Read supporting files (jid, iq, events, mod, lib)
  • Provide review feedback

Overall this is a well-structured feature addition that follows the existing crate patterns nicely. The MEX/IQ split, the feature handle pattern, and the E2E test coverage are all solid. Here are the findings:

Issues

1. NewsletterMessage and NewsletterReactionCount not re-exported from crate rootsrc/lib.rs:48-57

src/features/mod.rs exports NewsletterMessage and NewsletterReactionCount, but src/lib.rs doesn't re-export them. Users accessing these types would need whatsapp_rust::features::NewsletterMessage instead of whatsapp_rust::NewsletterMessage, inconsistent with how other feature types are surfaced.

Fix this →

2. Silent defaults in parse_newsletter_metadata hide malformed payloadssrc/features/newsletter.rs:435-455

I partially agree with CodeRabbit here. The name field defaulting to "" (line 435) and subscriber_count defaulting to 0 (lines 441-444) are reasonable for optional/new-account cases. However, the verification and state catch-all defaults are more concerning:

// Line 446-448: Unknown verification string silently becomes Unverified
let verification = match thread["verification"].as_str() {
    Some("VERIFIED") => NewsletterVerification::Verified,
    _ => NewsletterVerification::Unverified,
};

// Line 451-454: Unknown state string silently becomes Active
let state = match value["state"]["type"].as_str() {
    Some("suspended") => NewsletterState::Suspended,
    Some("geosuspended") => NewsletterState::Geosuspended,
    _ => NewsletterState::Active,
};

A suspended newsletter appearing as Active is a correctness bug waiting to happen. Consider either:

  • Logging a warning when an unknown variant is encountered, or
  • Adding an Unknown(String) variant to both enums so callers can distinguish

The name and subscriber_count defaults are fine — new/empty newsletters legitimately have these.

3. Duplicated reaction parsing logicsrc/features/newsletter.rs:553-574 and src/handlers/notification.rs:1161-1182

The reaction parsing in parse_newsletter_messages_response and handle_newsletter_notification is nearly identical. This is a minor DRY concern — if the wire format changes, both sites need updating. Consider extracting a shared helper like parse_reaction_nodes(node: &Node) -> Vec<(String, u64)>.

4. server_id defaults to 0 on parse failuresrc/features/newsletter.rs:522-527 and src/handlers/notification.rs:1154-1159

let server_id = msg_node
    .attrs.get("server_id")
    .map(|v| v.as_str())
    .and_then(|s| s.parse::<u64>().ok())
    .unwrap_or(0);

A server_id of 0 is used as the pagination cursor in get_messages. If a malformed message silently gets server_id=0, pagination could break in subtle ways. Consider returning an error or at least skipping messages with unparseable server IDs.

5. send_message hardcodes type="text"src/features/newsletter.rs:356

let stanza = NodeBuilder::new("message")
    .attr("to", jid.clone())
    .attr("type", "text")  // Always "text" even for media messages

The method accepts any wa::Message (which can contain media), but always marks the stanza as type="text". If media messages are intended to be supported later, this could cause server-side rejection. The docstring says "Send a message to a newsletter" without restricting to text — either restrict the API or derive the type from the message content.

Minor / Nits

6. newsletter_jid in notification handler could be invalidsrc/handlers/notification.rs:1133

let newsletter_jid = node.attrs().jid("from");

If from is missing or unparseable, this would panic or produce a default JID. The other notification handlers in the same file tend to use optional_jid with early returns. Worth checking the jid() method's behavior on missing attributes.

7. E2E test assertions could be tightertests/e2e/tests/newsletter.rs:293-296

assert!(
    page.len() <= 4,
    "page before last should have fewer messages"
);

This assertion (<= 4) is very loose for a test that sends exactly 5 messages. A tighter bound would catch regressions better — e.g., assert!(page.len() >= 1 && page.len() <= 4) to also ensure results are returned.

8. Missing PartialEq on enum typessrc/features/newsletter.rs:22-42

NewsletterVerification, NewsletterState, and NewsletterRole don't derive PartialEq/Eq, making it awkward for users to compare values. Other similar enums in the codebase (e.g., ConnectFailureReason) derive these.

Looks Good

  • Clean separation of MEX (GraphQL) for metadata vs IQ for message operations
  • Jid::newsletter() factory and is_newsletter() trait method are consistent with existing JID patterns
  • NEWSLETTER_XMLNS and mex doc constants are well-organized
  • E2E tests are comprehensive — especially the multi-client join test and the reaction live update test with event waiting
  • Notification handler correctly falls back to raw Event::Notification for unknown newsletter notification types
  • The before cursor pagination API is intuitive

@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: 778fff93b3

ℹ️ 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 on lines +354 to +358
let stanza = NodeBuilder::new("message")
.attr("to", jid.clone())
.attr("type", "text")
.attr("id", &request_id)
.children([NodeBuilder::new("plaintext").bytes(encoded).build()])

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Support newsletter media uploads before accepting arbitrary messages

send_message() accepts any wa::Message, but this implementation always just wraps the protobuf in <plaintext> and sends it. That is fine for plain text, but newsletter image/video/document sends need the newsletter-specific upload metadata (including the unencrypted upload handle) and this crate currently has no path to attach it—src/upload.rs only implements the encrypted Client::upload() flow. As soon as a caller passes image_message, video_message, etc., the API will emit a malformed channel send even though the signature suggests those message types are supported.

Useful? React with 👍 / 👎.

Comment thread src/handlers/notification.rs Outdated
Comment on lines +1191 to +1195
if !messages.is_empty() {
client
.core
.event_bus
.dispatch(&Event::NewsletterLiveUpdate(NewsletterLiveUpdate {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Preserve raw newsletter notifications after parsing live updates

Handling type="newsletter" here stops these stanzas from reaching the default Event::Notification path, but this branch only emits Event::NewsletterLiveUpdate. Existing consumers that watch raw notifications therefore stop seeing newsletter stanzas, and any newsletter notification fields that are not represented in NewsletterLiveUpdate are now dropped entirely. handle_group_notification() keeps the raw dispatch for backward compatibility, so this is a behavior regression specific to newsletter notifications.

Useful? React with 👍 / 👎.

@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: 4

♻️ Duplicate comments (1)
src/features/newsletter.rs (1)

439-459: ⚠️ Potential issue | 🟠 Major

Fail fast on malformed metadata instead of defaulting to real values.

Line 439-Line 459 currently collapse missing/unknown fields into valid-looking defaults ("", 0, Unverified, Active), which can hide payload/schema issues and misstate channel status.

🛠️ Example hardening
-    let name = thread["name"]["text"].as_str().unwrap_or("").to_string();
+    let name = thread["name"]["text"]
+        .as_str()
+        .filter(|s| !s.is_empty())
+        .ok_or_else(|| MexError::PayloadParsing("missing newsletter name".into()))?
+        .to_string();

-    let subscriber_count = thread["subscribers_count"]
-        .as_str()
-        .and_then(|s| s.parse::<u64>().ok())
-        .unwrap_or(0);
+    let subscriber_count = thread["subscribers_count"]
+        .as_u64()
+        .or_else(|| thread["subscribers_count"].as_str().and_then(|s| s.parse::<u64>().ok()))
+        .ok_or_else(|| MexError::PayloadParsing("missing newsletter subscriber count".into()))?;

-    let verification = match thread["verification"].as_str() {
-        Some("VERIFIED") => NewsletterVerification::Verified,
-        _ => NewsletterVerification::Unverified,
-    };
+    let verification = match thread["verification"].as_str() {
+        Some("VERIFIED") => NewsletterVerification::Verified,
+        Some("UNVERIFIED") => NewsletterVerification::Unverified,
+        Some(v) => return Err(MexError::PayloadParsing(format!("unknown verification: {v}"))),
+        None => return Err(MexError::PayloadParsing("missing verification".into())),
+    };

-    let state = match value["state"]["type"].as_str() {
+    let state = match value["state"]["type"].as_str() {
         Some("suspended") => NewsletterState::Suspended,
         Some("geosuspended") => NewsletterState::Geosuspended,
-        _ => NewsletterState::Active,
+        Some("active") => NewsletterState::Active,
+        Some(s) => return Err(MexError::PayloadParsing(format!("unknown state: {s}"))),
+        None => return Err(MexError::PayloadParsing("missing state.type".into())),
     };
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/features/newsletter.rs` around lines 439 - 459, The parsing currently
masks malformed metadata by defaulting fields (name, description,
subscriber_count, verification, state) to valid-looking values; change the logic
in the newsletter parsing block to fail fast: validate that
thread["name"]["text"] and thread["description"]["text"] exist and are non-empty
(return Err or propagate Result) instead of using unwrap_or(""), parse
subscriber_count with explicit error handling (propagate parse failure instead
of defaulting to 0), and treat unknown verification or state strings as errors
rather than mapping to NewsletterVerification::Unverified or
NewsletterState::Active; update the function signature to return Result if
needed, and replace the unwrap_or/and_then/default branches for name,
description, subscriber_count, verification, and state with explicit matches
that return an error on missing/invalid values so malformed payloads are
surfaced immediately.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/features/newsletter.rs`:
- Around line 320-337: The subscribe_live_updates function is using
client.send_iq with an owned Jid and bypasses the standard IQ path; change it to
use the client's execute(...) pattern with an IqSpec that takes &Jid (not Jid)
so the request goes through the standard IQ execution path: create an IqSpec (or
existing Spec type used project-wide) for NEWSLETTER_XMLNS and the
"live_updates" node using &jid, call self.client.execute(spec).await?, then
extract the "duration" from the returned response as before; apply the same
replacement to the other newsletter methods noted (the ones at lines 408-415) so
all newsletter IQs use client.execute(Spec::new(&jid)).await? and IqSpec
constructors that accept &Jid.
- Around line 553-565: The code currently synthesizes server_id and timestamp
from msg_node.attrs using unwrap_or(0), which produces ambiguous 0 values for
malformed nodes; change the parsing in the msg_node handling (the server_id and
timestamp extraction) to treat parse failures as invalid messages rather than
defaulting to 0 — return an error or skip/ignore the msg_node and emit a clear
log/metric when attrs.get("server_id") or attrs.get("t") is missing or fails to
parse to u64, and ensure downstream code that uses server_id/timestamp expects
that these fields may be absent (propagate Option<u64> or Result), so no
synthetic 0 values are stored or used for pagination/event correlation.

In `@src/handlers/notification.rs`:
- Around line 1140-1164: The code currently assigns server_id = 0 when parsing
fails, leaking bogus updates; change the mapping over children to skip malformed
message nodes by using filter_map (or an early return None) so you only
construct NewsletterLiveUpdateMessage when attrs.get("server_id") exists and
.parse::<u64>() succeeds; keep the reactions logic using parse_reaction_counts
and only collect NewsletterLiveUpdateMessage instances for which server_id was
valid (i.e., reference the closure that builds NewsletterLiveUpdateMessage, the
server_id parsing logic, and parse_reaction_counts) so invalid/malformed message
nodes are omitted instead of producing server_id = 0.

In `@tests/e2e/tests/newsletter.rs`:
- Around line 293-296: The assertion is too permissive given the request used
count=2; update the check on page.len() (in tests/e2e/tests/newsletter.rs) to
require the requested page size instead of <=4 — replace the current
assert!(page.len() <= 4, ...) with an assertion that page.len() == 2 (e.g.,
assert_eq!(page.len(), 2, "expected page to contain exactly count=2 items")) so
the test enforces the requested pagination size.

---

Duplicate comments:
In `@src/features/newsletter.rs`:
- Around line 439-459: The parsing currently masks malformed metadata by
defaulting fields (name, description, subscriber_count, verification, state) to
valid-looking values; change the logic in the newsletter parsing block to fail
fast: validate that thread["name"]["text"] and thread["description"]["text"]
exist and are non-empty (return Err or propagate Result) instead of using
unwrap_or(""), parse subscriber_count with explicit error handling (propagate
parse failure instead of defaulting to 0), and treat unknown verification or
state strings as errors rather than mapping to
NewsletterVerification::Unverified or NewsletterState::Active; update the
function signature to return Result if needed, and replace the
unwrap_or/and_then/default branches for name, description, subscriber_count,
verification, and state with explicit matches that return an error on
missing/invalid values so malformed payloads are surfaced immediately.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 08e846ed-7d00-4b6c-a476-e9373054d055

📥 Commits

Reviewing files that changed from the base of the PR and between 5c472c7 and 609a00d.

📒 Files selected for processing (7)
  • src/features/mod.rs
  • src/features/newsletter.rs
  • src/handlers/notification.rs
  • src/lib.rs
  • tests/e2e/tests/newsletter.rs
  • wacore/src/iq/newsletter.rs
  • wacore/src/types/events.rs
🚧 Files skipped from review as they are similar to previous changes (2)
  • src/lib.rs
  • wacore/src/iq/newsletter.rs

Comment on lines +320 to +337
pub async fn subscribe_live_updates(&self, jid: &Jid) -> Result<u64, anyhow::Error> {
let iq = InfoQuery::set(
NEWSLETTER_XMLNS,
jid.clone(),
Some(NodeContent::Nodes(vec![
NodeBuilder::new("live_updates").build(),
])),
);

let response = self.client.send_iq(iq).await?;
let duration = response
.get_optional_child("live_updates")
.and_then(|n| n.attrs.get("duration"))
.map(|v| v.as_str())
.and_then(|s| s.parse::<u64>().ok())
.unwrap_or(300);

Ok(duration)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion | 🟠 Major

Use the standard IQ execution path for newsletter IQ operations.

These methods bypass the guideline-preferred IQ request path by calling send_iq directly with owned/cloned JIDs.

As per coding guidelines use client.execute(Spec::new(&jid)).await? pattern for IQ requests, with IqSpec constructors taking &Jid not Jid.

Also applies to: 408-415

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/features/newsletter.rs` around lines 320 - 337, The
subscribe_live_updates function is using client.send_iq with an owned Jid and
bypasses the standard IQ path; change it to use the client's execute(...)
pattern with an IqSpec that takes &Jid (not Jid) so the request goes through the
standard IQ execution path: create an IqSpec (or existing Spec type used
project-wide) for NEWSLETTER_XMLNS and the "live_updates" node using &jid, call
self.client.execute(spec).await?, then extract the "duration" from the returned
response as before; apply the same replacement to the other newsletter methods
noted (the ones at lines 408-415) so all newsletter IQs use
client.execute(Spec::new(&jid)).await? and IqSpec constructors that accept &Jid.

Comment thread src/features/newsletter.rs Outdated
Comment on lines +553 to +565
let server_id = msg_node
.attrs
.get("server_id")
.map(|v| v.as_str())
.and_then(|s| s.parse::<u64>().ok())
.unwrap_or(0);

let timestamp = msg_node
.attrs
.get("t")
.map(|v| v.as_str())
.and_then(|s| s.parse::<u64>().ok())
.unwrap_or(0);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Do not synthesize message IDs/timestamps from malformed nodes.

Defaulting invalid server_id/t to 0 can produce ambiguous records and break downstream pagination/event correlation.

🛠️ Suggested parser tightening
-    for msg_node in children.iter().filter(|n| n.tag.as_ref() == "message") {
-        let server_id = msg_node
+    for msg_node in children.iter().filter(|n| n.tag.as_ref() == "message") {
+        let Some(server_id) = msg_node
             .attrs
             .get("server_id")
             .map(|v| v.as_str())
-            .and_then(|s| s.parse::<u64>().ok())
-            .unwrap_or(0);
+            .and_then(|s| s.parse::<u64>().ok()) else {
+                continue;
+            };

-        let timestamp = msg_node
+        let Some(timestamp) = msg_node
             .attrs
             .get("t")
             .map(|v| v.as_str())
-            .and_then(|s| s.parse::<u64>().ok())
-            .unwrap_or(0);
+            .and_then(|s| s.parse::<u64>().ok()) else {
+                continue;
+            };
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/features/newsletter.rs` around lines 553 - 565, The code currently
synthesizes server_id and timestamp from msg_node.attrs using unwrap_or(0),
which produces ambiguous 0 values for malformed nodes; change the parsing in the
msg_node handling (the server_id and timestamp extraction) to treat parse
failures as invalid messages rather than defaulting to 0 — return an error or
skip/ignore the msg_node and emit a clear log/metric when attrs.get("server_id")
or attrs.get("t") is missing or fails to parse to u64, and ensure downstream
code that uses server_id/timestamp expects that these fields may be absent
(propagate Option<u64> or Result), so no synthetic 0 values are stored or used
for pagination/event correlation.

Comment thread src/handlers/notification.rs
Comment on lines +293 to +296
assert!(
page.len() <= 4,
"page before last should have fewer messages"
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Tighten pagination assertions to match requested page size.

With count=2 (Line 290), asserting page.len() <= 4 is too permissive and can mask regressions.

🧪 Suggested assertion fix
-    assert!(
-        page.len() <= 4,
-        "page before last should have fewer messages"
-    );
+    assert!(page.len() <= 2, "page size should respect requested count");
+    assert!(
+        page.iter().all(|m| m.server_id < last_server_id),
+        "all paginated messages should be older than the cursor"
+    );
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/e2e/tests/newsletter.rs` around lines 293 - 296, The assertion is too
permissive given the request used count=2; update the check on page.len() (in
tests/e2e/tests/newsletter.rs) to require the requested page size instead of <=4
— replace the current assert!(page.len() <= 4, ...) with an assertion that
page.len() == 2 (e.g., assert_eq!(page.len(), 2, "expected page to contain
exactly count=2 items")) so the test enforces the requested pagination size.

@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

🧹 Nitpick comments (2)
src/features/newsletter.rs (2)

500-523: Consider skipping reactions with empty code.

Reactions with empty code (defaulting via unwrap_or_default()) are likely malformed and could confuse downstream consumers. A filter or filter_map approach would be cleaner.

♻️ Optional tightening
     for r in children.iter().filter(|n| n.tag.as_ref() == "reaction") {
-        let code = r
+        let Some(code) = r
             .attrs
             .get("code")
             .map(|v| v.as_str().into_owned())
-            .unwrap_or_default();
+            .filter(|s| !s.is_empty())
+        else {
+            continue;
+        };
         let count = r
             .attrs
             .get("count")
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/features/newsletter.rs` around lines 500 - 523, In parse_reaction_counts,
skip reactions whose "code" attribute is empty instead of defaulting to an empty
string: when iterating children in parse_reaction_counts, filter out entries
where r.attrs.get("code") is missing or maps to an empty string (or use a
filter_map to only collect Some(code) where code.trim().is_empty() is false),
then parse the "count" and push NewsletterReactionCount { code, count } only for
non-empty codes so malformed reactions are ignored by downstream consumers.

563-568: Timestamp still defaults to 0 for malformed nodes.

While server_id validation was improved (nodes without valid server_id are now skipped), timestamp still defaults to 0 when missing or unparseable. This can produce ambiguous records. Consider skipping or logging a warning for messages with invalid timestamps as well.

♻️ Suggested tightening
-        let timestamp = msg_node
-            .attrs
-            .get("t")
-            .map(|v| v.as_str())
-            .and_then(|s| s.parse::<u64>().ok())
-            .unwrap_or(0);
+        let Some(timestamp) = msg_node
+            .attrs
+            .get("t")
+            .map(|v| v.as_str())
+            .and_then(|s| s.parse::<u64>().ok())
+        else {
+            log::debug!("Skipping newsletter message with missing/invalid timestamp");
+            continue;
+        };
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/features/newsletter.rs` around lines 563 - 568, The timestamp extraction
currently falls back to 0 for missing/unparseable values (the let timestamp =
msg_node.attrs.get("t").map(...).and_then(...).unwrap_or(0) line), which creates
ambiguous records; instead, detect when msg_node.attrs.get("t") is absent or
s.parse::<u64>() fails and either log a warning via your logger and skip
processing this msg_node or return/continue early so malformed timestamps are
not recorded—update the code that handles timestamp parsing to avoid
unwrap_or(0) and use an explicit match/if-let to handle Err/None paths
(mirroring the server_id validation flow) and reference the same logging/skip
behavior used for invalid server_id.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/handlers/notification.rs`:
- Line 1134: Replace the direct call to node.attrs().jid("from") with the
defensive pattern used elsewhere: call optional_jid on node.attrs() to obtain
newsletter_jid and return early if it is None (similar to
handle_picture_notification). Specifically, locate the expression
node.attrs().jid("from") and change the logic to use optional_jid(node.attrs(),
"from") (or the existing optional_jid helper with the same semantics), assign
the result to newsletter_jid only if present, and perform an early return when
newsletter_jid is missing to avoid panics.

---

Nitpick comments:
In `@src/features/newsletter.rs`:
- Around line 500-523: In parse_reaction_counts, skip reactions whose "code"
attribute is empty instead of defaulting to an empty string: when iterating
children in parse_reaction_counts, filter out entries where r.attrs.get("code")
is missing or maps to an empty string (or use a filter_map to only collect
Some(code) where code.trim().is_empty() is false), then parse the "count" and
push NewsletterReactionCount { code, count } only for non-empty codes so
malformed reactions are ignored by downstream consumers.
- Around line 563-568: The timestamp extraction currently falls back to 0 for
missing/unparseable values (the let timestamp =
msg_node.attrs.get("t").map(...).and_then(...).unwrap_or(0) line), which creates
ambiguous records; instead, detect when msg_node.attrs.get("t") is absent or
s.parse::<u64>() fails and either log a warning via your logger and skip
processing this msg_node or return/continue early so malformed timestamps are
not recorded—update the code that handles timestamp parsing to avoid
unwrap_or(0) and use an explicit match/if-let to handle Err/None paths
(mirroring the server_id validation flow) and reference the same logging/skip
behavior used for invalid server_id.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: b9612dc1-b588-4ee9-9685-7fc0ead41069

📥 Commits

Reviewing files that changed from the base of the PR and between 609a00d and 7f31180.

📒 Files selected for processing (3)
  • src/features/newsletter.rs
  • src/handlers/notification.rs
  • tests/e2e/tests/newsletter.rs
🚧 Files skipped from review as they are similar to previous changes (1)
  • tests/e2e/tests/newsletter.rs

Comment thread src/handlers/notification.rs Outdated
Add newsletter feature module with read-only Mex GraphQL operations:
- list_subscribed() — list all subscribed newsletters
- get_metadata(&Jid) — fetch newsletter metadata by JID
- get_metadata_by_invite(&str) — fetch metadata by invite code

New types: NewsletterMetadata, NewsletterVerification, NewsletterState,
NewsletterRole, exposed via client.newsletter() feature handle.

Also adds:
- Jid::newsletter() factory method for zero-alloc JID construction
- Mex document ID constants for all newsletter GraphQL operations
- E2E test stubs for list_subscribed and get_metadata
- Add create(name, description) via Mex mutation (doc 25149874324715067)
- Add join(jid) via Mex mutation (doc 24404358912487870)
- Rewrite e2e tests to: create → list → get_metadata → get_by_invite → join
- All 3 e2e tests pass against mock server
- Add leave(jid) via Mex mutation (doc 9767147403369991)
- Add update(jid, name, description) via Mex mutation (doc 24250201037901610)
- Add LEAVE doc ID constant
- E2E tests: 5/5 passing (create, list, metadata, join, leave, update)
- send_message(jid, message) — plaintext stanza (no Signal encryption)
- get_messages(jid, count, before) — IQ xmlns="newsletter" with
  server_id pagination cursor
- NewsletterMessage type with decoded protobuf + reaction counts
- Leave mutation doc ID (9767147403369991, from WA Web JS)
- E2E tests: 7/7 passing (send, history, pagination, all CRUD ops)
- subscribe_live_updates(jid) — IQ SET xmlns="newsletter" <live_updates/>
- send_reaction(jid, server_id, emoji) — reaction message stanza
- NewsletterLiveUpdate event with per-message reaction counts
- Notification handler for type="newsletter" parses <live_updates>
  children and dispatches Event::NewsletterLiveUpdate
- E2E tests: 9/9 passing (full lifecycle including reaction live updates)
- Re-export NewsletterMessage and NewsletterReactionCount from crate root
- Extract shared parse_reaction_counts() helper, used by both message
  history parser and notification handler (DRY)
- Dispatch raw Event::Notification for newsletter notifications alongside
  Event::NewsletterLiveUpdate for backward compatibility (matches group
  handler pattern)
- Document media upload limitation on send_message()
- Skip message nodes without valid server_id instead of defaulting to 0
  (both in history parser and notification handler)
- Tighten pagination test assertion from <= 4 to <= 2 (matches count=2)
- Use optional_jid("from") with early return instead of jid("from")
  which returns Jid::default() on missing attrs (matches other handlers)
- Skip reactions with empty code attribute instead of defaulting to ""
@jlucaso1
jlucaso1 force-pushed the feat/newsletter-phase1 branch from 44272c9 to 74d8eea Compare March 18, 2026 19:54

@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

♻️ Duplicate comments (3)
src/features/newsletter.rs (3)

566-571: ⚠️ Potential issue | 🟡 Minor

Avoid synthesizing timestamp = 0 for malformed message nodes.

Using 0 creates ambiguous records and weakens downstream ordering/correlation. Skip invalid nodes (or make timestamp optional) instead of manufacturing a value.

Suggested change
-        let timestamp = msg_node
-            .attrs
-            .get("t")
-            .map(|v| v.as_str())
-            .and_then(|s| s.parse::<u64>().ok())
-            .unwrap_or(0);
+        let Some(timestamp) = msg_node
+            .attrs
+            .get("t")
+            .map(|v| v.as_str())
+            .and_then(|s| s.parse::<u64>().ok())
+        else {
+            continue;
+        };
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/features/newsletter.rs` around lines 566 - 571, The code currently
assigns timestamp = 0 when parsing msg_node.attrs.get("t") fails; instead,
change the handling in the parsing logic around msg_node to skip malformed nodes
or make the timestamp an Option<u64> by returning None when .parse::<u64>().ok()
is None; locate the block that builds timestamp (the chain using
msg_node.attrs.get("t").map(|v| v.as_str()).and_then(|s|
s.parse::<u64>().ok()).unwrap_or(0)) and remove the unwrap_or(0), either
filtering out the msg_node when the parse returns None or propagating
Option<u64> (e.g., timestamp_opt) so downstream code can handle missing
timestamps explicitly.

320-337: ⚠️ Potential issue | 🟠 Major

Use the standard IQ execution path for newsletter IQ methods.

subscribe_live_updates and get_messages are still bypassing the project’s preferred IQ pipeline via send_iq + owned/cloned JIDs. Please route these through the standard execute(Spec...) path with &Jid to keep behavior and middleware handling consistent.

As per coding guidelines: "Use client.execute(Spec::new(&jid)).await? pattern for IQ requests, with IqSpec constructors taking &Jid not Jid."

Also applies to: 408-415

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/features/newsletter.rs` around lines 320 - 337, The
subscribe_live_updates function (and similarly get_messages) currently builds an
InfoQuery and calls self.client.send_iq with an owned/cloned Jid; change this to
use the project's IQ pipeline by creating the appropriate IqSpec (or
IqSpec::new/InfoQuerySpec) that accepts a &Jid and passing it to
self.client.execute(spec).await? instead of send_iq, and stop cloning/owning the
Jid—use &jid throughout so middleware, timeouts, and handlers are applied
consistently via execute(Spec::new(&jid)). Ensure the spec constructor you use
mirrors the set/query from InfoQuery::set but takes &Jid as per the IqSpec
constructors.

439-459: ⚠️ Potential issue | 🟠 Major

Fail fast on malformed metadata instead of defaulting to valid-looking values.

Defaulting name, subscriber_count, verification, and state masks payload/schema issues and can surface incorrect newsletter state (e.g., unknown state treated as Active).

Suggested hardening
-    let name = thread["name"]["text"].as_str().unwrap_or("").to_string();
+    let name = thread["name"]["text"]
+        .as_str()
+        .filter(|s| !s.is_empty())
+        .ok_or_else(|| MexError::PayloadParsing("missing newsletter name".into()))?
+        .to_string();

-    let subscriber_count = thread["subscribers_count"]
-        .as_str()
-        .and_then(|s| s.parse::<u64>().ok())
-        .unwrap_or(0);
+    let subscriber_count = thread["subscribers_count"]
+        .as_u64()
+        .or_else(|| thread["subscribers_count"].as_str().and_then(|s| s.parse::<u64>().ok()))
+        .ok_or_else(|| MexError::PayloadParsing("missing/invalid subscribers_count".into()))?;

-    let verification = match thread["verification"].as_str() {
-        Some("VERIFIED") => NewsletterVerification::Verified,
-        _ => NewsletterVerification::Unverified,
-    };
+    let verification = match thread["verification"].as_str() {
+        Some("VERIFIED") => NewsletterVerification::Verified,
+        Some("UNVERIFIED") => NewsletterVerification::Unverified,
+        Some(other) => return Err(MexError::PayloadParsing(format!("unknown verification: {other}"))),
+        None => return Err(MexError::PayloadParsing("missing verification".into())),
+    };

-    let state = match value["state"]["type"].as_str() {
-        Some("suspended") => NewsletterState::Suspended,
-        Some("geosuspended") => NewsletterState::Geosuspended,
-        _ => NewsletterState::Active,
-    };
+    let state = match value["state"]["type"].as_str() {
+        Some("active") => NewsletterState::Active,
+        Some("suspended") => NewsletterState::Suspended,
+        Some("geosuspended") => NewsletterState::Geosuspended,
+        Some(other) => return Err(MexError::PayloadParsing(format!("unknown state: {other}"))),
+        None => return Err(MexError::PayloadParsing("missing state".into())),
+    };
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/features/newsletter.rs` around lines 439 - 459, The current parsing
silently defaults malformed/missing fields (name, subscriber_count,
verification, state) which masks payload/schema errors; update the parsing in
the newsletter deserialization to fail fast by returning an Err when required
fields are missing or when values are unrecognized: for thread["name"]["text"]
and thread["description"]["text"] validate presence and non-empty (return Err on
missing required name), parse thread["subscribers_count"] using .as_str() and
.parse::<u64>() and return Err if parsing fails, and for thread["verification"]
and value["state"]["type"] map only known strings to NewsletterVerification and
NewsletterState and return Err for any unknown/absent variant so callers can
handle malformed payloads instead of silently using
NewsletterVerification::Unverified or NewsletterState::Active.
🧹 Nitpick comments (1)
src/features/newsletter.rs (1)

244-251: Add a guard for empty newsletter updates.

When both name and description are None, this sends an empty updates object. Consider failing fast client-side with a clear validation error.

Suggested guard
     let mut updates = json!({});
     if let Some(name) = name {
         updates["name"] = json!(name);
     }
     if let Some(desc) = description {
         updates["description"] = json!(desc);
     }
+    if updates.as_object().is_some_and(|m| m.is_empty()) {
+        return Err(MexError::PayloadParsing(
+            "update requires at least one field (name or description)".into(),
+        ));
+    }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/features/newsletter.rs` around lines 244 - 251, The handler currently
builds an empty JSON patch object (`let mut updates = json!({});`) and proceeds
even when both `name` and `description` are None; add a guard that checks for
no-op updates (i.e., both `name` and `description` are None or `updates` remains
empty) and return a clear client validation error instead of sending an empty
update. Locate the block around `let mut updates = json!({});` and the `if let
Some(name)` / `if let Some(desc)` branches and add an early return (e.g.,
Err/HttpResponse::BadRequest) with a message like "no updates provided" when
nothing changed. Ensure the returned error uses the same error type/response
conventions as the surrounding function.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/handlers/notification.rs`:
- Around line 1134-1136: The code currently returns early when
node.attrs().optional_jid("from") is None, which prevents the
backward-compatible raw notification path; instead, when newsletter_jid is
missing call the raw dispatch path by emitting Event::Notification (or invoking
the existing raw notification dispatch function) before returning. Concretely,
in the notification handling block where you call
node.attrs().optional_jid("from") (and bind newsletter_jid), replace the early
return with code that constructs and dispatches an Event::Notification fallback
(using the same payload the raw path expects) and then return; ensure both
places noted (the current block and the similar block around the other
occurrence) follow the same pattern so missing `from` triggers the raw
Event::Notification dispatch rather than skipping it.

---

Duplicate comments:
In `@src/features/newsletter.rs`:
- Around line 566-571: The code currently assigns timestamp = 0 when parsing
msg_node.attrs.get("t") fails; instead, change the handling in the parsing logic
around msg_node to skip malformed nodes or make the timestamp an Option<u64> by
returning None when .parse::<u64>().ok() is None; locate the block that builds
timestamp (the chain using msg_node.attrs.get("t").map(|v|
v.as_str()).and_then(|s| s.parse::<u64>().ok()).unwrap_or(0)) and remove the
unwrap_or(0), either filtering out the msg_node when the parse returns None or
propagating Option<u64> (e.g., timestamp_opt) so downstream code can handle
missing timestamps explicitly.
- Around line 320-337: The subscribe_live_updates function (and similarly
get_messages) currently builds an InfoQuery and calls self.client.send_iq with
an owned/cloned Jid; change this to use the project's IQ pipeline by creating
the appropriate IqSpec (or IqSpec::new/InfoQuerySpec) that accepts a &Jid and
passing it to self.client.execute(spec).await? instead of send_iq, and stop
cloning/owning the Jid—use &jid throughout so middleware, timeouts, and handlers
are applied consistently via execute(Spec::new(&jid)). Ensure the spec
constructor you use mirrors the set/query from InfoQuery::set but takes &Jid as
per the IqSpec constructors.
- Around line 439-459: The current parsing silently defaults malformed/missing
fields (name, subscriber_count, verification, state) which masks payload/schema
errors; update the parsing in the newsletter deserialization to fail fast by
returning an Err when required fields are missing or when values are
unrecognized: for thread["name"]["text"] and thread["description"]["text"]
validate presence and non-empty (return Err on missing required name), parse
thread["subscribers_count"] using .as_str() and .parse::<u64>() and return Err
if parsing fails, and for thread["verification"] and value["state"]["type"] map
only known strings to NewsletterVerification and NewsletterState and return Err
for any unknown/absent variant so callers can handle malformed payloads instead
of silently using NewsletterVerification::Unverified or NewsletterState::Active.

---

Nitpick comments:
In `@src/features/newsletter.rs`:
- Around line 244-251: The handler currently builds an empty JSON patch object
(`let mut updates = json!({});`) and proceeds even when both `name` and
`description` are None; add a guard that checks for no-op updates (i.e., both
`name` and `description` are None or `updates` remains empty) and return a clear
client validation error instead of sending an empty update. Locate the block
around `let mut updates = json!({});` and the `if let Some(name)` / `if let
Some(desc)` branches and add an early return (e.g.,
Err/HttpResponse::BadRequest) with a message like "no updates provided" when
nothing changed. Ensure the returned error uses the same error type/response
conventions as the surrounding function.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 7dab9705-3c59-41a2-8a0d-efca3ebd85ad

📥 Commits

Reviewing files that changed from the base of the PR and between 7f31180 and 74d8eea.

📒 Files selected for processing (9)
  • src/features/mod.rs
  • src/features/newsletter.rs
  • src/handlers/notification.rs
  • src/lib.rs
  • tests/e2e/tests/newsletter.rs
  • wacore/binary/src/jid.rs
  • wacore/src/iq/mod.rs
  • wacore/src/iq/newsletter.rs
  • wacore/src/types/events.rs
🚧 Files skipped from review as they are similar to previous changes (4)
  • src/features/mod.rs
  • tests/e2e/tests/newsletter.rs
  • wacore/src/types/events.rs
  • src/lib.rs

Comment on lines +1134 to +1136
let Some(newsletter_jid) = node.attrs().optional_jid("from") else {
return;
};

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Preserve raw notification fallback when from is missing.

The early return on missing from skips the backward-compat raw dispatch path. Consider dispatching Event::Notification before returning.

Suggested fix
     let Some(newsletter_jid) = node.attrs().optional_jid("from") else {
+        warn!(target: "Client/Newsletter", "newsletter notification missing 'from' attribute");
+        client
+            .core
+            .event_bus
+            .dispatch(&Event::Notification(node.clone()));
         return;
     };

Also applies to: 1178-1183

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/handlers/notification.rs` around lines 1134 - 1136, The code currently
returns early when node.attrs().optional_jid("from") is None, which prevents the
backward-compatible raw notification path; instead, when newsletter_jid is
missing call the raw dispatch path by emitting Event::Notification (or invoking
the existing raw notification dispatch function) before returning. Concretely,
in the notification handling block where you call
node.attrs().optional_jid("from") (and bind newsletter_jid), replace the early
return with code that constructs and dispatches an Event::Notification fallback
(using the same payload the raw path expects) and then return; ensure both
places noted (the current block and the similar block around the other
occurrence) follow the same pattern so missing `from` triggers the raw
Event::Notification dispatch rather than skipping it.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant