|
| 1 | +# Using Outbox |
| 2 | + |
| 3 | +[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. |
| 4 | + |
| 5 | +This guide explains how to implement a simple Outbox. |
| 6 | + |
| 7 | +## Define Empty Outbox |
| 8 | + |
| 9 | +First, you must define the Outbox in order to use it: |
| 10 | + |
| 11 | +```python |
| 12 | +app.outbox("/users/{identifier}/outbox") |
| 13 | +``` |
| 14 | + |
| 15 | +The Outbox is handled by subscribing to the special `Outbox` class from `apkit.types` using the `app.on` decorator: |
| 16 | + |
| 17 | +```python |
| 18 | +from apkit.models import OrderedCollection, Person |
| 19 | +from apkit.server.types import Context |
| 20 | +from apkit.server.responses import ActivityResponse |
| 21 | + |
| 22 | +... |
| 23 | + |
| 24 | +person = Person( |
| 25 | + ... |
| 26 | + outbox="https://example.com/users/1/outbox" |
| 27 | +) |
| 28 | + |
| 29 | +@app.on(Outbox) |
| 30 | +async def listen_outbox(ctx: Context): |
| 31 | + identifier = ctx.request.path_params.get("identifier") |
| 32 | + col = OrderedCollection( |
| 33 | + id=f"https://example.com/users/{identifier}/outbox", |
| 34 | + total_items=0, |
| 35 | + ordered_items=[] |
| 36 | + ) |
| 37 | + return ActivityResponse(col) |
| 38 | +``` |
| 39 | + |
| 40 | +## Returning real data |
| 41 | +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.) |
| 42 | + |
| 43 | +However, this time let us retrieve and return actual data. |
| 44 | + |
| 45 | +```python |
| 46 | +from datetime import datetime |
| 47 | + |
| 48 | +from apkit.models import Announce, Create, Delete, Note, Tombstone, Person, OrderedCollection, OrderedCollectionPage |
| 49 | +from fastapi.responses import JSONResponse |
| 50 | + |
| 51 | +person = Person(id="https://example.com/users/alice") |
| 52 | + |
| 53 | +PAGE_SIZE = 20 |
| 54 | +posts = [ |
| 55 | + Announce( |
| 56 | + id="https://example.com/users/alice/activities/4", |
| 57 | + actor=person.id, |
| 58 | + published=datetime(2026, 1, 3), |
| 59 | + to=["https://www.w3.org/ns/activitystreams#Public"], |
| 60 | + object="https://example.net/users/bob/notes/2", |
| 61 | + ), |
| 62 | + Delete( |
| 63 | + id="https://example.com/users/alice/activities/3", |
| 64 | + actor=person.id, |
| 65 | + published=datetime(2026, 1, 2), |
| 66 | + to=["https://www.w3.org/ns/activitystreams#Public"], |
| 67 | + object=Tombstone( |
| 68 | + id="https://example.com/users/alice/notes/2", |
| 69 | + ), |
| 70 | + ), |
| 71 | + Create( |
| 72 | + id="https://example.com/users/alice/activities/1", |
| 73 | + actor=person.id, |
| 74 | + published=datetime(2026, 1, 1), |
| 75 | + to=["https://www.w3.org/ns/activitystreams#Public"], |
| 76 | + object=Note( |
| 77 | + id="https://example.com/users/alice/notes/1", |
| 78 | + attributedTo="https://example.com/users/alice", |
| 79 | + content="<p>Hello World!</p>", |
| 80 | + published=datetime(2026, 1, 1), |
| 81 | + to=["https://www.w3.org/ns/activitystreams#Public"] |
| 82 | + ), |
| 83 | + ) |
| 84 | +] |
| 85 | + |
| 86 | +@app.on(Outbox) |
| 87 | +async def listen_outbox(ctx: Context): |
| 88 | + identifier = ctx.request.path_params.get("identifier") |
| 89 | + if identifier != "alice": |
| 90 | + return JSONResponse({"message": "Not Found"}, status_code=404) |
| 91 | + outbox_url = f"https://example.com/users/{identifier}/outbox" |
| 92 | + |
| 93 | + is_page = ctx.request.query_params.get("page") == "true" |
| 94 | + max_id = ctx.request.query_params.get("max_id") |
| 95 | + |
| 96 | + if not is_page: |
| 97 | + col = OrderedCollection( |
| 98 | + id=outbox_url, |
| 99 | + total_items=len(posts), |
| 100 | + first=f"{outbox_url}?page=true", |
| 101 | + last=f"{outbox_url}?page=true&min_id={posts[-1].id}" if posts else None |
| 102 | + ) |
| 103 | + return ActivityResponse(col) |
| 104 | + |
| 105 | + start_index = 0 |
| 106 | + if max_id: |
| 107 | + for i, p in enumerate(posts): |
| 108 | + if p.id == max_id: |
| 109 | + start_index = i + 1 |
| 110 | + break |
| 111 | + |
| 112 | + page_items = posts[start_index : start_index + PAGE_SIZE] |
| 113 | + |
| 114 | + next_url = None |
| 115 | + if start_index + PAGE_SIZE < len(posts): |
| 116 | + last_item_id = page_items[-1].id |
| 117 | + next_url = f"{outbox_url}?page=true&max_id={last_item_id}" |
| 118 | + |
| 119 | + page = OrderedCollectionPage( |
| 120 | + id=f"{outbox_url}?page=true" + (f"&max_id={max_id}" if max_id else ""), |
| 121 | + part_of=outbox_url, |
| 122 | + ordered_items=page_items, |
| 123 | + next=next_url |
| 124 | + ) |
| 125 | + return ActivityResponse(page) |
| 126 | + |
| 127 | +``` |
| 128 | + |
| 129 | +!!! tips "What is `Tombstone`?" |
| 130 | + |
| 131 | + 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." |
0 commit comments