v0.5.0 — codebase refinement, flexible API, architecture cleanup
All checks were successful
CI / fmt (push) Successful in 31s
CI / clippy (push) Successful in 4m0s
CI / test (push) Successful in 5m44s

Error handling:
  thiserror enum (NotFound/BadRequest/Unauthorized/Forbidden/Internal)
  eliminates 41 boilerplate .map_err() calls
  signature failures return 401, not 500

Named types:
  Keypair, LocalObject (with to/cc/bto/bcc addressing), Addressing

Readability:
  descriptive names everywhere, small functions, breathing room
  noisy comments removed, intent-explicit error handling (no let _ =)
  types.rs per module separating data from behavior

File organization:
  handlers/ module (actor, featured, followers, inbox, nodeinfo, outbox, webfinger)
  actors/ split (mod.rs + person.rs + types.rs)
  service/ split (builder, broadcast, collections, delivery, fetch, follow, lookup, types)
  tests next to modules

Repository traits:
  FollowRepository → 5 sub-traits (FollowerWriter/Reader, FollowingWriter/Reader, FollowMigration)
  ActorRepository → 3 sub-traits (KeypairRepository, RemoteActorCache, AnnounceRepository)
  BlocklistRepository → 2 sub-traits (DomainBlocklist, ActorBlocklist)
  supertraits with blanket impls — existing consumers unchanged
  FollowMigration has default no-op
  delete dead get_following_outbox_url

Testing:
  mock_repo! macro generates mock builders from compact specs
  MockFollowRepo, MockActorRepo, MockBlocklistRepo, MockActivityRepo,
  MockUserRepo, MockContentReader, MockObjectHandler, MockEventPublisher
  all hand-written test stubs replaced

Flexibility:
  UrlScheme trait — configurable URL patterns (DefaultUrlScheme = /users/{uuid})
  on_unknown_activity hook for custom AP extensions
  broadcast_raw_to_followers for arbitrary activity JSON
  broadcast_create/broadcast_update (renamed from Note-centric names)
  internal modules locked to pub(crate), clean public re-exports
  actor_handler, followers_handler, following_handler re-exported for custom routers

Security:
  SSRF: block IPv6-mapped private IPv4, TEST-NET, benchmarking, reserved ranges
  verify_attributed_to rejects missing/array attributedTo
  remove .expect() from outbox handler

Architecture:
  handlers/followers.rs delegates to serialize_ordered_collection (no more UrlScheme bypass)
  extract dispatch_sends, prepare_addressed_broadcast (eliminate duplication)
  DbActor::object_id(), RemoteActor::from/from_ap_person/placeholder
  send_activity unifies prepare+dispatch, deterministic_activity_id helper
  pass-through wrappers grouped in lookup.rs
This commit is contained in:
2026-07-25 17:01:44 +02:00
parent 17b57fb9b5
commit b569efe715
73 changed files with 3583 additions and 3160 deletions

View File

@@ -1,6 +1,4 @@
use activitypub_federation::{
fetch::object_id::ObjectId, protocol::context::WithContext, traits::Object,
};
use activitypub_federation::{protocol::context::WithContext, traits::Object};
use url::Url;
use crate::{
@@ -8,144 +6,138 @@ use crate::{
AddActivity, AnnounceActivity, CreateActivity, DeleteActivity, MoveActivity, UndoActivity,
UpdateActivity,
},
actors::get_local_actor,
urls::activity_url,
actors::{DbActor, get_local_actor},
data::FederationData,
user::ApVisibility,
};
use super::ActivityPubService;
use super::types::{AddRef, AddRefObject, AnnounceRef, LikeRef, TombstoneRef};
// Re-export so existing `crate::service::broadcast::{Addressing, visibility_addressing}` paths keep working.
#[allow(unused_imports)]
pub(crate) use super::types::Addressing;
pub(crate) use super::types::visibility_addressing;
fn deterministic_activity_id(
base_url: &str,
prefix: &str,
user_id: uuid::Uuid,
object_url: &Url,
) -> anyhow::Result<Url> {
let namespace_input = format!("{}/{}", user_id, object_url);
let deterministic_id =
uuid::Uuid::new_v5(&uuid::Uuid::NAMESPACE_URL, namespace_input.as_bytes());
Ok(Url::parse(&format!(
"{}/activities/{}/{}",
base_url, prefix, deterministic_id
))?)
}
impl ActivityPubService {
pub async fn broadcast_announce_to_followers(
&self,
local_user_id: uuid::Uuid,
object_ap_id: url::Url,
object_ap_id: Url,
) -> anyhow::Result<()> {
let announce_id = url::Url::parse(&format!(
"{}/activities/announce/{}",
self.base_url,
uuid::Uuid::new_v5(
&uuid::Uuid::NAMESPACE_URL,
format!("{}/{}", local_user_id, object_ap_id).as_bytes()
),
))
.map_err(|e| anyhow::anyhow!("{e}"))?;
let announce_id =
deterministic_activity_id(&self.base_url, "announce", local_user_id, &object_ap_id)?;
let data = self.federation_config.to_request_data();
let Some((local_actor, inboxes)) =
self.accepted_follower_inboxes(&data, local_user_id).await?
else {
return Ok(());
};
let announce = AnnounceActivity {
id: announce_id,
kind: Default::default(),
actor: ObjectId::from(local_actor.ap_id.clone()),
actor: local_actor.object_id(),
object: object_ap_id,
published: Some(chrono::Utc::now()),
to: vec![crate::urls::AS_PUBLIC.to_string()],
cc: vec![local_actor.followers_url.to_string()],
};
let (json, sends, inboxes) = self
.prepare_broadcast(&data, &local_actor, inboxes, announce)
.await?;
self.dispatch_deliveries(&data, &local_actor, inboxes, sends, json)
self.send_activity(&data, &local_actor, inboxes, announce)
.await
}
pub async fn broadcast_undo_announce_to_followers(
&self,
local_user_id: uuid::Uuid,
object_ap_id: url::Url,
object_ap_id: Url,
) -> anyhow::Result<()> {
let announce_id = url::Url::parse(&format!(
"{}/activities/announce/{}",
self.base_url,
uuid::Uuid::new_v5(
&uuid::Uuid::NAMESPACE_URL,
format!("{}/{}", local_user_id, object_ap_id).as_bytes()
),
))
.map_err(|e| anyhow::anyhow!("{e}"))?;
let undo_id = activity_url(&self.base_url).map_err(|e| anyhow::anyhow!("{e}"))?;
let announce_id =
deterministic_activity_id(&self.base_url, "announce", local_user_id, &object_ap_id)?;
let data = self.federation_config.to_request_data();
let Some((local_actor, inboxes)) =
self.accepted_follower_inboxes(&data, local_user_id).await?
else {
return Ok(());
};
let undo = UndoActivity {
id: undo_id,
id: data.url_scheme.activity_url(&self.base_url)?,
kind: Default::default(),
actor: ObjectId::from(local_actor.ap_id.clone()),
object: serde_json::json!({"type":"Announce","id":announce_id.to_string(),"actor":local_actor.ap_id.to_string(),"object":object_ap_id.to_string()}),
actor: local_actor.object_id(),
object: serde_json::to_value(AnnounceRef {
kind: "Announce",
id: announce_id.to_string(),
actor: local_actor.ap_id.to_string(),
object: object_ap_id.to_string(),
})?,
};
let (json, sends, inboxes) = self
.prepare_broadcast(&data, &local_actor, inboxes, undo)
.await?;
self.dispatch_deliveries(&data, &local_actor, inboxes, sends, json)
.await
self.send_activity(&data, &local_actor, inboxes, undo).await
}
pub async fn broadcast_like_to_inbox(
&self,
liker_user_id: uuid::Uuid,
object_ap_id: url::Url,
author_inbox_url: url::Url,
object_ap_id: Url,
author_inbox_url: Url,
) -> anyhow::Result<()> {
let data = self.federation_config.to_request_data();
let local_actor = get_local_actor(liker_user_id, &data)
.await
.map_err(|e| anyhow::anyhow!("{e}"))?;
let like_id = url::Url::parse(&format!(
"{}/activities/like/{}",
self.base_url,
uuid::Uuid::new_v5(
&uuid::Uuid::NAMESPACE_URL,
format!("{}/{}", liker_user_id, object_ap_id).as_bytes()
),
))?;
let local_actor = get_local_actor(liker_user_id, &data).await?;
let like_id =
deterministic_activity_id(&self.base_url, "like", liker_user_id, &object_ap_id)?;
let like = crate::activities::LikeActivity {
id: like_id,
kind: Default::default(),
actor: ObjectId::from(local_actor.ap_id.clone()),
actor: local_actor.object_id(),
object: object_ap_id,
};
let (json, sends, inboxes) = self
.prepare_broadcast(&data, &local_actor, vec![author_inbox_url], like)
.await?;
self.dispatch_deliveries(&data, &local_actor, inboxes, sends, json)
self.send_activity(&data, &local_actor, vec![author_inbox_url], like)
.await
}
pub async fn broadcast_undo_like_to_inbox(
&self,
liker_user_id: uuid::Uuid,
object_ap_id: url::Url,
author_inbox_url: url::Url,
object_ap_id: Url,
author_inbox_url: Url,
) -> anyhow::Result<()> {
let data = self.federation_config.to_request_data();
let local_actor = get_local_actor(liker_user_id, &data)
.await
.map_err(|e| anyhow::anyhow!("{e}"))?;
let like_id = url::Url::parse(&format!(
"{}/activities/like/{}",
self.base_url,
uuid::Uuid::new_v5(
&uuid::Uuid::NAMESPACE_URL,
format!("{}/{}", liker_user_id, object_ap_id).as_bytes()
),
))?;
let undo_id = activity_url(&self.base_url).map_err(|e| anyhow::anyhow!("{e}"))?;
let local_actor = get_local_actor(liker_user_id, &data).await?;
let like_id =
deterministic_activity_id(&self.base_url, "like", liker_user_id, &object_ap_id)?;
let undo = UndoActivity {
id: undo_id,
id: data.url_scheme.activity_url(&self.base_url)?,
kind: Default::default(),
actor: ObjectId::from(local_actor.ap_id.clone()),
object: serde_json::json!({"type":"Like","id":like_id.to_string(),"actor":local_actor.ap_id.to_string(),"object":object_ap_id.to_string()}),
actor: local_actor.object_id(),
object: serde_json::to_value(LikeRef {
kind: "Like",
id: like_id.to_string(),
actor: local_actor.ap_id.to_string(),
object: object_ap_id.to_string(),
})?,
};
let (json, sends, inboxes) = self
.prepare_broadcast(&data, &local_actor, vec![author_inbox_url], undo)
.await?;
self.dispatch_deliveries(&data, &local_actor, inboxes, sends, json)
self.send_activity(&data, &local_actor, vec![author_inbox_url], undo)
.await
}
@@ -160,18 +152,20 @@ impl ActivityPubService {
else {
return Ok(());
};
let delete = DeleteActivity {
id: activity_url(&self.base_url).map_err(|e| anyhow::anyhow!("{e}"))?,
id: data.url_scheme.activity_url(&self.base_url)?,
kind: Default::default(),
actor: ObjectId::from(local_actor.ap_id.clone()),
object: serde_json::json!({"type": "Tombstone", "id": ap_id.to_string()}),
actor: local_actor.object_id(),
object: serde_json::to_value(TombstoneRef {
kind: "Tombstone",
id: ap_id.to_string(),
})?,
to: vec![crate::urls::AS_PUBLIC.to_string()],
cc: vec![local_actor.followers_url.to_string()],
};
let (json, sends, inboxes) = self
.prepare_broadcast(&data, &local_actor, inboxes, delete)
.await?;
self.dispatch_deliveries(&data, &local_actor, inboxes, sends, json)
self.send_activity(&data, &local_actor, inboxes, delete)
.await
}
@@ -187,25 +181,23 @@ impl ActivityPubService {
else {
return Ok(());
};
let add = AddActivity {
id: ap_id,
kind: Default::default(),
actor: ObjectId::from(local_actor.ap_id.clone()),
actor: local_actor.object_id(),
object,
to: vec![crate::urls::AS_PUBLIC.to_string()],
cc: vec![local_actor.followers_url.to_string()],
};
let (json, sends, inboxes) = self
.prepare_broadcast(&data, &local_actor, inboxes, add)
.await?;
self.dispatch_deliveries(&data, &local_actor, inboxes, sends, json)
.await
self.send_activity(&data, &local_actor, inboxes, add).await
}
pub async fn broadcast_undo_add_to_followers(
&self,
local_user_id: uuid::Uuid,
watchlist_entry_ap_id: Url,
object_ap_id: Url,
) -> anyhow::Result<()> {
let data = self.federation_config.to_request_data();
let Some((local_actor, inboxes)) =
@@ -213,153 +205,136 @@ impl ActivityPubService {
else {
return Ok(());
};
let undo = UndoActivity {
id: activity_url(&self.base_url).map_err(|e| anyhow::anyhow!("{e}"))?,
id: data.url_scheme.activity_url(&self.base_url)?,
kind: Default::default(),
actor: ObjectId::from(local_actor.ap_id.clone()),
object: serde_json::json!({"type":"Add","id":watchlist_entry_ap_id.as_str(),"object":{"id":watchlist_entry_ap_id.as_str()}}),
actor: local_actor.object_id(),
object: serde_json::to_value(AddRef {
kind: "Add",
id: object_ap_id.to_string(),
object: AddRefObject {
id: object_ap_id.to_string(),
},
})?,
};
let (json, sends, inboxes) = self
.prepare_broadcast(&data, &local_actor, inboxes, undo)
.await?;
self.dispatch_deliveries(&data, &local_actor, inboxes, sends, json)
.await
self.send_activity(&data, &local_actor, inboxes, undo).await
}
/// Fan out a Create(Note) activity to accepted followers and any explicitly
/// Resolve the local actor, gather follower + mentioned inboxes, and compute
/// `to`/`cc` addressing. Returns `None` when visibility is `Private` or there
/// are no inboxes to deliver to.
async fn prepare_addressed_broadcast(
&self,
local_user_id: uuid::Uuid,
visibility: ApVisibility,
mentioned_inboxes: Vec<Url>,
) -> anyhow::Result<
Option<(
activitypub_federation::config::Data<FederationData>,
DbActor,
Vec<Url>,
Addressing,
)>,
> {
if visibility == ApVisibility::Private {
return Ok(None);
}
let data = self.federation_config.to_request_data();
let local_actor = get_local_actor(local_user_id, &data).await?;
let follower_inboxes = data
.follow_repo
.get_accepted_follower_inboxes(local_user_id)
.await?;
let inboxes = merge_inboxes(follower_inboxes, mentioned_inboxes);
if inboxes.is_empty() {
return Ok(None);
}
let addressing = visibility_addressing(visibility, &local_actor.followers_url);
Ok(Some((data, local_actor, inboxes, addressing)))
}
/// Fan out a Create activity to accepted followers and any explicitly
/// mentioned actors.
///
/// `visibility` controls `to`/`cc` addressing and whether the note is public:
/// `visibility` controls `to`/`cc` addressing:
/// - `Public` / `FollowersOnly`: delivered to followers + `mentioned_inboxes`
/// - `Private`: returns immediately — no delivery to anyone
///
/// `mentioned_inboxes` should contain the inbox URLs of remote actors
/// explicitly tagged in the note who are not already followers. Resolve them
/// explicitly tagged in the object who are not already followers. Resolve them
/// via [`ActivityPubService::lookup_actor_by_handle`] before calling. Pass an
/// empty `Vec` if there are no external mentions.
pub async fn broadcast_create_note(
pub async fn broadcast_create(
&self,
local_user_id: uuid::Uuid,
note: serde_json::Value,
object: serde_json::Value,
visibility: ApVisibility,
mentioned_inboxes: Vec<Url>,
) -> anyhow::Result<()> {
if visibility == ApVisibility::Private {
let Some((data, local_actor, inboxes, addressing)) = self
.prepare_addressed_broadcast(local_user_id, visibility, mentioned_inboxes)
.await?
else {
return Ok(());
}
let data = self.federation_config.to_request_data();
let local_actor = crate::actors::get_local_actor(local_user_id, &data)
.await
.map_err(|e| anyhow::anyhow!("{e}"))?;
};
// Merge follower inboxes with explicitly mentioned actor inboxes,
// deduplicating by string to avoid delivering the same inbox twice.
let follower_inboxes = data
.follow_repo
.get_accepted_follower_inboxes(local_user_id)
.await?;
let mut seen = std::collections::HashSet::new();
let mut inboxes: Vec<Url> = follower_inboxes
.into_iter()
.filter_map(|s| Url::parse(&s).ok())
.filter(|u| seen.insert(u.to_string()))
.collect();
for inbox in mentioned_inboxes {
if seen.insert(inbox.to_string()) {
inboxes.push(inbox);
}
}
if inboxes.is_empty() {
return Ok(());
}
let note_id_str = note["id"].as_str().unwrap_or("");
let object_id_str = object["id"].as_str().unwrap_or("");
let create_id = Url::parse(&format!(
"{}/activities/create/{}",
self.base_url,
uuid::Uuid::new_v5(&uuid::Uuid::NAMESPACE_URL, note_id_str.as_bytes())
))
.map_err(|e| anyhow::anyhow!("{e}"))?;
let (to, cc) = visibility_addressing(visibility, &local_actor.followers_url);
uuid::Uuid::new_v5(&uuid::Uuid::NAMESPACE_URL, object_id_str.as_bytes())
))?;
let create = CreateActivity {
id: create_id,
kind: Default::default(),
actor: ObjectId::from(local_actor.ap_id.clone()),
object: note,
to,
cc,
actor: local_actor.object_id(),
object,
to: addressing.to,
cc: addressing.cc,
bto: vec![],
bcc: vec![],
};
let (json, sends, inboxes) = self
.prepare_broadcast(&data, &local_actor, inboxes, create)
.await?;
self.dispatch_deliveries(&data, &local_actor, inboxes, sends, json)
self.send_activity(&data, &local_actor, inboxes, create)
.await
}
/// Fan out an Update(Note) activity to accepted followers and mentioned actors.
/// See [`broadcast_create_note`] for `mentioned_inboxes` semantics.
pub async fn broadcast_update_note(
/// Fan out an Update activity to accepted followers and mentioned actors.
/// See [`ActivityPubService::broadcast_create`] for `mentioned_inboxes` semantics.
pub async fn broadcast_update(
&self,
local_user_id: uuid::Uuid,
note: serde_json::Value,
object: serde_json::Value,
visibility: ApVisibility,
mentioned_inboxes: Vec<Url>,
) -> anyhow::Result<()> {
if visibility == ApVisibility::Private {
let Some((data, local_actor, inboxes, addressing)) = self
.prepare_addressed_broadcast(local_user_id, visibility, mentioned_inboxes)
.await?
else {
return Ok(());
}
let data = self.federation_config.to_request_data();
let local_actor = crate::actors::get_local_actor(local_user_id, &data)
.await
.map_err(|e| anyhow::anyhow!("{e}"))?;
let follower_inboxes = data
.follow_repo
.get_accepted_follower_inboxes(local_user_id)
.await?;
let mut seen = std::collections::HashSet::new();
let mut inboxes: Vec<Url> = follower_inboxes
.into_iter()
.filter_map(|s| Url::parse(&s).ok())
.filter(|u| seen.insert(u.to_string()))
.collect();
for inbox in mentioned_inboxes {
if seen.insert(inbox.to_string()) {
inboxes.push(inbox);
}
}
if inboxes.is_empty() {
return Ok(());
}
let (to, cc) = visibility_addressing(visibility, &local_actor.followers_url);
let update = crate::activities::UpdateActivity {
id: activity_url(&self.base_url).map_err(|e| anyhow::anyhow!("{e}"))?,
kind: Default::default(),
actor: ObjectId::from(local_actor.ap_id.clone()),
object: note,
to,
cc,
};
let (json, sends, inboxes) = self
.prepare_broadcast(&data, &local_actor, inboxes, update)
.await?;
self.dispatch_deliveries(&data, &local_actor, inboxes, sends, json)
let update = UpdateActivity {
id: data.url_scheme.activity_url(&self.base_url)?,
kind: Default::default(),
actor: local_actor.object_id(),
object,
to: addressing.to,
cc: addressing.cc,
};
self.send_activity(&data, &local_actor, inboxes, update)
.await
}
pub async fn broadcast_actor_update(&self, user_id: uuid::Uuid) -> anyhow::Result<()> {
let data = self.federation_config.to_request_data();
let local_actor = get_local_actor(user_id, &data)
.await
.map_err(|e| anyhow::anyhow!("{e}"))?;
let person = local_actor
.clone()
.into_json(&data)
.await
.map_err(|e| anyhow::anyhow!("{e}"))?;
let local_actor = get_local_actor(user_id, &data).await?;
let person = local_actor.clone().into_json(&data).await?;
let person_json =
serde_json::to_value(WithContext::new(person, crate::urls::actor_ap_context()))?;
let update_id = Url::parse(&format!(
@@ -370,65 +345,81 @@ impl ActivityPubService {
let update = UpdateActivity {
id: update_id,
kind: Default::default(),
actor: ObjectId::from(local_actor.ap_id.clone()),
actor: local_actor.object_id(),
object: person_json,
to: vec![crate::urls::AS_PUBLIC.to_string()],
cc: vec![local_actor.followers_url.to_string()],
};
let Some((_, inboxes)) = self.accepted_follower_inboxes(&data, user_id).await? else {
tracing::info!(%user_id, "no accepted followers, skipping actor update broadcast");
return Ok(());
};
tracing::info!(%user_id, inbox_count = inboxes.len(), "broadcasting actor update");
let (json, sends, inboxes) = self
.prepare_broadcast(&data, &local_actor, inboxes, update)
.await?;
self.dispatch_deliveries(&data, &local_actor, inboxes, sends, json)
self.send_activity(&data, &local_actor, inboxes, update)
.await
}
pub async fn broadcast_move(
&self,
user_id: uuid::Uuid,
new_actor_url: url::Url,
new_actor_url: Url,
) -> anyhow::Result<()> {
let data = self.federation_config.to_request_data();
let local_actor = get_local_actor(user_id, &data)
.await
.map_err(|e| anyhow::anyhow!("{e}"))?;
let local_actor = get_local_actor(user_id, &data).await?;
let Some((_, inboxes)) = self.accepted_follower_inboxes(&data, user_id).await? else {
tracing::info!(%user_id, "broadcast_move: no accepted followers");
return Ok(());
};
let move_activity = MoveActivity {
id: activity_url(&self.base_url).map_err(|e| anyhow::anyhow!("{e}"))?,
id: data.url_scheme.activity_url(&self.base_url)?,
kind: Default::default(),
actor: ObjectId::from(local_actor.ap_id.clone()),
actor: local_actor.object_id(),
object: local_actor.ap_id.clone(),
target: new_actor_url.clone(),
};
let (json, sends, inboxes) = self
.prepare_broadcast(&data, &local_actor, inboxes, move_activity)
.await?;
self.dispatch_deliveries(&data, &local_actor, inboxes, sends, json)
self.send_activity(&data, &local_actor, inboxes, move_activity)
.await?;
tracing::info!(%user_id, target = %new_actor_url, "broadcast_move: dispatched");
Ok(())
}
}
/// Broadcast a pre-built activity to all accepted followers.
///
/// This is the low-level escape hatch for custom activity types that
/// k-ap doesn't have a dedicated method for. The `activity` JSON must
/// be a complete AP activity with `id`, `type`, `actor`, etc. already set.
/// k-ap wraps it in `@context` and handles signing + delivery.
pub async fn broadcast_raw_to_followers(
&self,
local_user_id: uuid::Uuid,
activity: serde_json::Value,
) -> anyhow::Result<()> {
let data = self.federation_config.to_request_data();
let Some((local_actor, inboxes)) =
self.accepted_follower_inboxes(&data, local_user_id).await?
else {
return Ok(());
};
/// Returns `(to, cc)` addressing for the given visibility.
/// `Private` is handled before calling this (early return in broadcast methods).
pub(crate) fn visibility_addressing(
visibility: ApVisibility,
followers_url: &Url,
) -> (Vec<String>, Vec<String>) {
match visibility {
ApVisibility::Public => (
vec![crate::urls::AS_PUBLIC.to_string()],
vec![followers_url.to_string()],
),
ApVisibility::FollowersOnly => (vec![followers_url.to_string()], vec![]),
ApVisibility::Private => (vec![], vec![]),
self.send_raw_activity(&data, &local_actor, inboxes, activity)
.await
}
}
fn merge_inboxes(follower_inboxes: Vec<String>, mentioned_inboxes: Vec<Url>) -> Vec<Url> {
let mut seen = std::collections::HashSet::new();
let mut inboxes: Vec<Url> = follower_inboxes
.into_iter()
.filter_map(|inbox_str| Url::parse(&inbox_str).ok())
.filter(|url| seen.insert(url.to_string()))
.collect();
for inbox in mentioned_inboxes {
if seen.insert(inbox.to_string()) {
inboxes.push(inbox);
}
}
inboxes
}