Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
131 changes: 131 additions & 0 deletions docs/guides/outbox.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
# Using Outbox

[Outbox](https://www.w3.org/wiki/ActivityPub/Primer/Outbox) is **required** by the specification, but most implementations recognize an Actor even if it doesn't have an Outbox.

This guide explains how to implement a simple Outbox.

## Define Empty Outbox

First, you must define the Outbox in order to use it:

```python
app.outbox("/users/{identifier}/outbox")
```

The Outbox is handled by subscribing to the special `Outbox` class from `apkit.types` using the `app.on` decorator:

```python
from apkit.models import OrderedCollection, Person
from apkit.server.types import Context
from apkit.server.responses import ActivityResponse

...

person = Person(
...
outbox="https://example.com/users/1/outbox"
)

@app.on(Outbox)
async def listen_outbox(ctx: Context):
identifier = ctx.request.path_params.get("identifier")
col = OrderedCollection(
id=f"https://example.com/users/{identifier}/outbox",
total_items=0,
ordered_items=[]
)
return ActivityResponse(col)
```

## Returning real data
In most cases, you don't actually need to return the contents. (However, some implementations use the contents of the outbox to count the number of posts.)

However, this time let us retrieve and return actual data.

```python
from datetime import datetime

from apkit.models import Announce, Create, Delete, Note, Tombstone, Person, OrderedCollection, OrderedCollectionPage
from fastapi.responses import JSONResponse

person = Person(id="https://example.com/users/alice")

PAGE_SIZE = 20
posts = [
Announce(
id="https://example.com/users/alice/activities/4",
actor=person.id,
published=datetime(2026, 1, 3),
to=["https://www.w3.org/ns/activitystreams#Public"],
object="https://example.net/users/bob/notes/2",
),
Delete(
id="https://example.com/users/alice/activities/3",
actor=person.id,
published=datetime(2026, 1, 2),
to=["https://www.w3.org/ns/activitystreams#Public"],
object=Tombstone(
id="https://example.com/users/alice/notes/2",
),
),
Create(
id="https://example.com/users/alice/activities/1",
actor=person.id,
published=datetime(2026, 1, 1),
to=["https://www.w3.org/ns/activitystreams#Public"],
object=Note(
id="https://example.com/users/alice/notes/1",
attributedTo="https://example.com/users/alice",
content="<p>Hello World!</p>",
published=datetime(2026, 1, 1),
to=["https://www.w3.org/ns/activitystreams#Public"]
),
)
]

@app.on(Outbox)
async def listen_outbox(ctx: Context):
identifier = ctx.request.path_params.get("identifier")
if identifier != "alice":
return JSONResponse({"message": "Not Found"}, status_code=404)
outbox_url = f"https://example.com/users/{identifier}/outbox"

is_page = ctx.request.query_params.get("page") == "true"
max_id = ctx.request.query_params.get("max_id")

if not is_page:
col = OrderedCollection(
id=outbox_url,
total_items=len(posts),
first=f"{outbox_url}?page=true",
last=f"{outbox_url}?page=true&min_id={posts[-1].id}" if posts else None
)
return ActivityResponse(col)

start_index = 0
if max_id:
for i, p in enumerate(posts):
if p.id == max_id:
start_index = i + 1
break

page_items = posts[start_index : start_index + PAGE_SIZE]

next_url = None
if start_index + PAGE_SIZE < len(posts):
last_item_id = page_items[-1].id
next_url = f"{outbox_url}?page=true&max_id={last_item_id}"

page = OrderedCollectionPage(
id=f"{outbox_url}?page=true" + (f"&max_id={max_id}" if max_id else ""),
part_of=outbox_url,
ordered_items=page_items,
next=next_url
)
return ActivityResponse(page)

```

!!! tips "What is `Tombstone`?"

The `Tombstone` type indicates content that existed in the past but has now been deleted. By returning this object instead of completely removing the item from the Outbox, you can explicitly communicate to the remote server that "this post has been deleted."
9 changes: 8 additions & 1 deletion examples/send_message.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import uuid
from datetime import UTC, datetime

from apmodel.vocab.mention import Mention
from cryptography.hazmat.primitives import serialization as crypto_serialization
from cryptography.hazmat.primitives.asymmetric import rsa

Expand Down Expand Up @@ -106,13 +107,19 @@ async def send_note(recepient: str) -> None:
logger.info(f"Found actor's inbox: {inbox_url}")

# Create note
t = f'<p><span class="h-card" translate="no"><a href="{target_actor.url}" class="u-url mention">@<span>{target_actor.preferred_username}</span></a></span></p>'
note = Note(
id=f"https://{HOST}/notes/{uuid.uuid4()}",
attributed_to=actor.id,
content="<p>Hello from apkit</p>",
content=f"<p>{t} Hello from apkit</p>",
published=datetime.now(UTC).isoformat() + "Z",
to=[target_actor.id],
cc=["https://www.w3.org/ns/activitystreams#Public"],
tag=[
Mention(
href=target_actor.url, name=f"@{target_actor.preferred_username}"
)
],
)

# Create activity
Expand Down
3 changes: 2 additions & 1 deletion mkdocs.yml
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
site_name: apkit
site_url: https://fedi-libs.github.io/apkit
site_url: https://apkit.fedi-libs.org
repo_url: https://github.com/fedi-libs/apkit
edit_uri: edit/main/docs/

Expand All @@ -11,6 +11,7 @@ nav:
- Configuration: guides/configuration.md
- Client: guides/client.md
- Server: guides/server.md
- Outbox: guides/outbox.md
- Models: guides/models.md
- KV Store: guides/kv_store.md
- Nodeinfo: guides/nodeinfo.md
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ dev = [
]
docs = [
"mkdocs>=1.6.1",
"mkdocs-material>=9.6.19",
"mkdocs-material==9.6.20",
]
lint = [
"pyrefly>=0.46.0",
Expand Down
31 changes: 16 additions & 15 deletions uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.