Introduction
AiMessage turns any Mac into an iMessage API server. Single binary, zero external dependencies.
How it works
- Read path: Polls
~/Library/Messages/chat.db(SQLite) for new messages and reactions, tracking the highest processed ROWID so it resumes correctly after restart. - Write path: Sends messages and attachments via
osascriptcontrolling Messages.app — no private frameworks required for basic sending.
What you can build
AiMessage exposes iMessage as a standard REST API with webhook and WebSocket delivery. Some examples:
- Chatbots and AI agents — receive incoming messages via webhook or WebSocket, process them, and reply via the send endpoint.
- CRM integrations — pipe conversations into your customer data platform by forwarding webhook events.
- Auto-responders — trigger automated replies based on keywords, schedules, or external conditions.
- Notification systems — use iMessage as a delivery channel for alerts, monitoring events, or two-factor codes.
Requirements
- macOS Ventura or later
- Rust toolchain (
rustup,cargo) - Messages.app signed into an Apple ID
Installation
Prerequisites
- macOS Ventura or later
- Rust toolchain: install via rustup
- Messages.app signed into an Apple ID
Clone and build
git clone <repo-url>
cd aimessage
cargo build --release
The release binary is output to target/release/aimessage.
Create the app bundle
Running AiMessage as a proper .app bundle is required for macOS to associate the Full Disk Access and Automation permissions with it. Running the bare binary from a terminal works only if you grant those permissions to your terminal emulator instead.
bash scripts/build-app.sh
This script compiles a release build and packages it into bundle/AiMessage.app. After it completes, grant the bundle Full Disk Access (see Permissions) and then launch it:
open bundle/AiMessage.app
What build-app.sh does
The script performs these steps:
- Runs
cargo build --release - Creates the
bundle/AiMessage.app/Contents/MacOS/directory structure - Copies the compiled binary into the bundle
- Writes a minimal
Info.plist
You need to re-run the script any time you rebuild the binary with new changes.
Permissions
AiMessage requires macOS permissions to read your message database and control Messages.app.
Summary
| Permission | Required for | How to grant |
|---|---|---|
| Full Disk Access | Reading chat.db | System Settings → Privacy & Security → Full Disk Access → add AiMessage.app |
| Automation | Sending messages via AppleScript | Prompted automatically on first launch; or System Settings → Privacy & Security → Automation |
Full Disk Access
Why: ~/Library/Messages/chat.db is protected by macOS’s TCC (Transparency, Consent, and Control) subsystem. Without Full Disk Access, any attempt to open the file returns a permission error regardless of Unix file permissions.
How to grant:
- Open System Settings → Privacy & Security → Full Disk Access
- Click the + button
- Navigate to
bundle/AiMessage.appand add it - Ensure the toggle next to AiMessage is enabled
If you are running the binary directly from a terminal (e.g. cargo run), you need to grant Full Disk Access to your terminal emulator (Terminal.app, iTerm2, etc.) instead.
Automation
Why: AiMessage sends messages by scripting Messages.app via osascript. macOS requires explicit user consent before one application can control another via AppleScript.
How to grant:
On first launch, macOS will display a dialog: “AiMessage wants to control Messages.” Click OK.
If you dismissed that dialog or need to re-grant it:
- Open System Settings → Privacy & Security → Automation
- Find AiMessage in the list
- Enable the Messages toggle beneath it
Configuration
Config file location
~/.aimessage/config.toml
First-run behavior
On the very first launch, AiMessage generates a config file with a random API key and then exits. This is intentional — it gives you a chance to review and adjust the config before the server starts accepting requests. The config file is created with permissions 0600 (owner read/write only).
Config generated at /Users/you/.aimessage/config.toml
Edit it if needed, then run again.
Launch AiMessage a second time to start the server.
Full config reference
[server]
host = "127.0.0.1" # Interface to bind. Use "0.0.0.0" to expose to the network.
port = 3001 # Port the HTTP server listens on.
[auth]
api_key = "your-generated-uuid" # All API requests require this in the X-API-Key header.
[imessage]
chat_db_path = "/Users/you/Library/Messages/chat.db" # Path to the iMessage SQLite database.
poll_interval_ms = 1000 # How often (in milliseconds) to check for new messages.
Field reference
[server]
| Field | Default | Description |
|---|---|---|
host | "127.0.0.1" | Bind address. Defaults to localhost only. Use "0.0.0.0" to accept connections from the network. |
port | 3001 | TCP port for the HTTP server. |
[auth]
| Field | Description |
|---|---|
api_key | A UUID generated on first run. Required on all API requests as the X-API-Key header. Change this to any string you prefer. |
[imessage]
| Field | Default | Description |
|---|---|---|
chat_db_path | Auto-detected | Path to chat.db. Auto-detected as ~/Library/Messages/chat.db. Override only if your database is in a non-standard location. |
poll_interval_ms | 1000 | Polling interval in milliseconds. Lower values reduce latency but increase CPU and disk I/O. |
Auto-detection of chat.db
If chat_db_path is not specified, AiMessage resolves it from $HOME. This covers the standard single-user macOS setup. Override it explicitly if you run AiMessage as a service under a different user account or have a non-standard Messages setup.
Finding your API key
cat ~/.aimessage/config.toml
Running the Server
Launch
open bundle/AiMessage.app
AiMessage runs as a menu bar application. When running, its icon appears in the macOS menu bar. Click the icon to see server status or quit.
First-run flow
- First launch: Config is generated at
~/.aimessage/config.tomland the process exits. Check the generated file. - Second launch: Server starts. On this launch (or the first launch after config already exists), macOS may prompt for Automation permission — click OK to allow AiMessage to control Messages.app.
- Server is running: The menu bar icon appears. The HTTP server is listening on the configured host and port (default
127.0.0.1:3001).
Verify the server is running
curl http://localhost:3001/api/v1/health
Expected response:
{"status":"ok","backend":{"connected":true,"message":null}}
connected: true means AiMessage has successfully opened chat.db. If this is false, check that Full Disk Access has been granted to the app bundle.
Running the bare binary
If you want to run without the app bundle (e.g. during development):
cargo run
This requires Full Disk Access to be granted to your terminal emulator. See Permissions for details.
To enable verbose logging:
RUST_LOG=aimessage=debug cargo run
Stopping the server
Send Ctrl+C or SIGTERM to stop the server. AiMessage performs a graceful shutdown — it drains in-flight connections before exiting, so no requests are cut off mid-response.
State persistence
AiMessage stores the last processed message ROWID in ~/.aimessage/aimessage.db. This ensures that after a restart, it resumes from where it left off rather than replaying all historical messages as new events.
Authentication
API key
All endpoints except /api/v1/health require authentication via the X-API-Key header.
curl -H "X-API-Key: your-api-key" http://localhost:3001/api/v1/messages
Finding your key
Your API key is generated on first run and stored in the config file:
cat ~/.aimessage/config.toml
[auth]
api_key = "550e8400-e29b-41d4-a716-446655440000"
You can change it to any string. Restart the server after editing the config.
Missing or invalid key
Any request without a valid X-API-Key header returns:
HTTP/1.1 401 Unauthorized
WebSocket authentication
The WebSocket endpoint does not support request headers in the initial handshake across all clients. Pass the key as a query parameter instead:
ws://localhost:3001/api/v1/ws?api_key=your-api-key
See WebSocket for full details.
Rate limiting
The API enforces a global limit of 60 requests per minute. Requests that exceed this limit receive 429 Too Many Requests. The rate limit applies across all endpoints (authenticated or not).
Health endpoint
GET /api/v1/health is unauthenticated and can be used to verify the server is running without a key.
Messages
Send a message
POST /api/v1/messages
Request body:
{
"recipient": "+15551234567",
"body": "Hello from AiMessage"
}
curl -X POST -H "X-API-Key: $KEY" -H "Content-Type: application/json" -d '{"recipient": "+15551234567", "body": "Hello from AiMessage"}' http://localhost:3001/api/v1/messages
The recipient field accepts a phone number or email address — anything Messages.app accepts as a destination.
Send with attachments
Include an attachments array of absolute file paths on the Mac running AiMessage:
curl -X POST -H "X-API-Key: $KEY" -H "Content-Type: application/json" -d '{"recipient": "+15551234567", "body": "Check this out", "attachments": ["/path/to/photo.png"]}' http://localhost:3001/api/v1/messages
To send a file without a text body:
curl -X POST -H "X-API-Key: $KEY" -H "Content-Type: application/json" -d '{"recipient": "+15551234567", "body": "", "attachments": ["/path/to/image.jpg"]}' http://localhost:3001/api/v1/messages
Multiple attachments can be included in a single request by adding more paths to the array.
List messages
GET /api/v1/messages
Query parameters
| Parameter | Type | Default | Description |
|---|---|---|---|
conversation_id | string | — | Filter by conversation GUID (e.g. iMessage;-;+15551234567). |
since | string | — | ISO 8601 timestamp. Returns messages after this time. |
limit | integer | 50 | Number of messages to return. Maximum 200. |
offset | integer | 0 | Pagination offset. |
Examples
# Most recent 10 messages across all conversations
curl -H "X-API-Key: $KEY" "http://localhost:3001/api/v1/messages?limit=10"
# Messages in a specific conversation
curl -H "X-API-Key: $KEY" "http://localhost:3001/api/v1/messages?conversation_id=iMessage;-;+15551234567&limit=10"
# Messages since a specific time
curl -H "X-API-Key: $KEY" "http://localhost:3001/api/v1/messages?since=2026-03-23T00:00:00Z"
Response
[
{
"id": "94711",
"guid": "F568F54A-1234-5678-ABCD-000000000000",
"conversation_id": "iMessage;-;+15551234567",
"sender": "+15551234567",
"body": "Hey!",
"attachments": [],
"timestamp": "2026-03-23T23:49:54Z",
"is_from_me": false,
"status": "delivered"
}
]
Incoming messages that include files have their attachment file paths in the attachments array:
{
"attachments": ["/Users/you/Library/Messages/Attachments/00/00/UUID/IMG_1234.jpeg"]
}
Get a message by ID
GET /api/v1/messages/{id}
id is the numeric ROWID from chat.db.
curl -H "X-API-Key: $KEY" http://localhost:3001/api/v1/messages/94711
Response
Same object shape as entries in the list response.
Conversations
Conversation IDs
Conversations are identified by their chat GUID from chat.db. The format is:
{service};-;{identifier}
Examples:
iMessage;-;+15551234567— iMessage with a phone numberiMessage;-;user@example.com— iMessage with an email addressSMS;-;+15551234567— SMS conversation
These GUIDs are returned in all message and conversation responses as conversation_id. Use them to filter messages or look up a specific conversation.
List conversations
GET /api/v1/conversations
Query parameters
| Parameter | Type | Default | Description |
|---|---|---|---|
limit | integer | 50 | Number of conversations to return. |
offset | integer | 0 | Pagination offset. |
Example
curl -H "X-API-Key: $KEY" "http://localhost:3001/api/v1/conversations?limit=10"
Response
[
{
"id": "iMessage;-;+15551234567",
"display_name": null,
"participants": ["+15551234567"],
"last_message_at": "2026-03-23T23:49:54Z"
}
]
display_name is set for named group conversations; it is null for 1-on-1 conversations.
Get a conversation by ID
GET /api/v1/conversations/{id}
The {id} is the full chat GUID, URL-encoded when necessary.
curl -H "X-API-Key: $KEY" "http://localhost:3001/api/v1/conversations/iMessage;-;+15551234567"
Response
{
"id": "iMessage;-;+15551234567",
"display_name": null,
"participants": ["+15551234567"],
"last_message_at": "2026-03-23T23:49:54Z"
}
Webhooks
Webhooks let you register an HTTP endpoint to receive real-time event notifications. When AiMessage detects a new message or reaction, it POSTs a JSON payload to every registered URL that subscribes to that event type.
Register a webhook
POST /api/v1/webhooks
Request body:
{
"url": "http://127.0.0.1:8080/webhook",
"events": ["message.received", "reaction.added"]
}
curl -X POST -H "X-API-Key: $KEY" -H "Content-Type: application/json" -d '{"url": "http://127.0.0.1:8080/webhook", "events": ["message.received", "reaction.added"]}' http://localhost:3001/api/v1/webhooks
With a secret
The secret field is optional. When provided, AiMessage signs every delivery using HMAC-SHA256 and includes the signature in the X-Webhook-Signature header in the format sha256=<hex>.
curl -X POST -H "X-API-Key: $KEY" -H "Content-Type: application/json" -d '{"url": "http://127.0.0.1:8080/webhook", "events": ["message.received"], "secret": "my-secret-token"}' http://localhost:3001/api/v1/webhooks
Verifying the signature:
Compute HMAC-SHA256(key=secret, message=raw_request_body) and compare the hex digest to the value after sha256= in the X-Webhook-Signature header. Use a constant-time comparison to prevent timing attacks.
Python example:
import hmac, hashlib
def verify(secret: str, body: bytes, header: str) -> bool:
expected = "sha256=" + hmac.new(secret.encode(), body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, header)
For local integrations, binding your webhook listener to 127.0.0.1 (as shown above) prevents external access and is recommended for single-machine setups.
Available events
| Event | When it fires |
|---|---|
message.received | An incoming message is detected in chat.db |
message.sent | An outgoing message sent by AiMessage is confirmed in chat.db |
reaction.added | A reaction is added to a message |
reaction.removed | A reaction is removed from a message |
Payload format
All events use the same envelope:
{
"type": "message.received",
"data": {
"id": "94711",
"guid": "F568F54A-1234-5678-ABCD-000000000000",
"conversation_id": "iMessage;-;+15551234567",
"sender": "+15551234567",
"body": "Hey!",
"attachments": [],
"timestamp": "2026-03-23T23:49:54Z",
"is_from_me": false,
"status": "delivered"
}
}
The data object shape varies by event type. For reaction.added and reaction.removed, it includes the reaction type and the ID of the message being reacted to.
Retry behavior
Failed deliveries (non-2xx response or connection error) are retried up to 3 times with backoff: 1 second, then 5 seconds. After 3 failures the delivery is dropped.
List webhooks
GET /api/v1/webhooks
curl -H "X-API-Key: $KEY" http://localhost:3001/api/v1/webhooks
Response:
[
{
"id": "a1b2c3d4-...",
"url": "http://127.0.0.1:8080/webhook",
"events": ["message.received"],
"created_at": "2026-03-23T12:00:00Z"
}
]
Delete a webhook
DELETE /api/v1/webhooks/{id}
curl -X DELETE -H "X-API-Key: $KEY" http://localhost:3001/api/v1/webhooks/a1b2c3d4-...
Returns 204 No Content on success.
WebSocket
The WebSocket endpoint streams all events in real-time as an alternative to webhooks. Multiple clients can connect simultaneously.
Connection URL
ws://localhost:3001/api/v1/ws?api_key=YOUR_KEY
Authentication is via query parameter because WebSocket handshake headers are not universally supported by client libraries.
Connect with websocat
websocat "ws://localhost:3001/api/v1/ws?api_key=YOUR_KEY"
Other compatible clients: wscat, any browser WebSocket API, or any language’s WebSocket library.
Event format
Each event is delivered as a JSON text frame with the same envelope format used by webhooks:
{"type":"message.received","data":{"id":"94711","guid":"F568F54A-...","conversation_id":"iMessage;-;+15551234567","sender":"+15551234567","body":"Hey!","attachments":[],"timestamp":"2026-03-23T23:49:54Z","is_from_me":false,"status":"delivered"}}
Events are not filtered — all event types are sent to all connected clients. If you only need specific event types, filter on the client side by checking the type field.
Lagged clients
AiMessage uses a broadcast channel internally. If a connected client is too slow to consume events, lagged events are skipped rather than buffered indefinitely. This prevents a slow consumer from causing unbounded memory growth. Design your client to process events promptly or accept that it may miss events under high load.
Example: Python client
import asyncio
import json
import websockets
async def listen():
url = "ws://localhost:3001/api/v1/ws?api_key=YOUR_KEY"
async with websockets.connect(url) as ws:
async for message in ws:
event = json.loads(message)
print(event["type"], event["data"])
asyncio.run(listen())
Health
The health endpoint is unauthenticated and is suitable for uptime checks, load balancer probes, and verifying the server started correctly.
Endpoint
GET /api/v1/health
curl http://localhost:3001/api/v1/health
Response
{
"status": "ok",
"backend": {
"connected": true,
"message": null
}
}
Fields
| Field | Type | Description |
|---|---|---|
status | string | Always "ok" when the server is running. |
backend.connected | boolean | true if AiMessage has successfully opened chat.db. false indicates a permissions problem — check Full Disk Access. |
backend.message | string or null | Optional diagnostic message. Non-null when there is a backend error or warning to report. |
Diagnosing issues
If connected is false, the most common cause is missing Full Disk Access for the app bundle. See Permissions.
Architecture Overview
AiMessage is organized into three layers. Each layer has a single responsibility, and dependencies only flow downward.
Layer diagram
┌─────────────────────────────────────────────────┐
│ API Layer (Axum) │
│ HTTP endpoints · Auth middleware · Request DTOs │
│ WebSocket handler · Route definitions │
└────────────────────────┬────────────────────────┘
│
▼
┌─────────────────────────────────────────────────┐
│ Core Layer │
│ MessageBackend trait · Domain types │
│ Webhook dispatcher with retry │
│ Broadcast channel (event bus) │
└────────────────────────┬────────────────────────┘
│
▼
┌─────────────────────────────────────────────────┐
│ iMessage Layer │
│ chat.db reader (ROWID polling) │
│ AppleScript sender (osascript) │
│ MessageBackend implementation │
└─────────────────────────────────────────────────┘
Data flow
Inbound (receiving messages)
- The iMessage layer polls
chat.dbeverypoll_interval_msmilliseconds, comparing the current max ROWID against the last processed ROWID. - New rows are read, parsed into
MessageorReactiondomain types, and wrapped inEventvariants. - Events are published to a
tokio::sync::broadcastchannel. - Two subscribers consume from that channel simultaneously:
- The webhook dispatcher (in the core layer) fans out to all registered webhook URLs with retry logic.
- Each connected WebSocket client receives the event as a JSON text frame.
Outbound (sending messages)
- An API request hits
POST /api/v1/messages. - The handler calls the
MessageBackendtrait methodsend_message. - The iMessage layer implementation invokes
osascriptwith the recipient and body. - Messages.app sends the message. The sent message eventually appears in
chat.dband is picked up by the polling loop as amessage.sentevent.
Broadcast channel
The broadcast channel is the central event bus. It decouples the iMessage poller from all consumers. Key properties:
- Multiple consumers (webhooks, WebSocket clients) subscribe independently.
- The channel has a fixed capacity. Lagged consumers (WebSocket clients that are too slow) have events skipped rather than the channel blocking.
- The webhook dispatcher is a single task that reads from the channel and fans out to all registered URLs concurrently.
Storage
AiMessage uses two SQLite databases:
| Database | Location | Contents |
|---|---|---|
chat.db | ~/Library/Messages/chat.db | iMessage data — read-only |
aimessage.db | ~/.aimessage/aimessage.db | App state: registered webhooks, last processed ROWID |
iMessage Integration
Reading chat.db
chat.db is a SQLite database that Messages.app maintains at ~/Library/Messages/chat.db. AiMessage opens it read-only in WAL (Write-Ahead Logging) mode, which allows concurrent reads while Messages.app is actively writing.
ROWID polling
AiMessage tracks the highest ROWID it has processed in its own aimessage.db. On each poll cycle:
- Query
chat.dbfor rows withROWID > last_processed_rowid. - Parse each new row into a domain type.
- Publish events to the broadcast channel.
- Update
last_processed_rowidinaimessage.db.
This approach is simple, reliable, and avoids filesystem events or SQLite triggers.
Timestamps
iMessage stores timestamps in Mac Absolute Time: seconds (or nanoseconds) since January 1, 2001 00:00:00 UTC.
To convert to Unix epoch, add the offset: 978,307,200 seconds.
macOS Ventura and later use nanoseconds for some timestamp fields. AiMessage detects this by checking whether the raw value is large enough to be nanoseconds (values above approximately 1e10 seconds would be in the far future if interpreted as seconds, so any value above that threshold is treated as nanoseconds and divided by 1e9 before applying the epoch offset).
Read-only safety
Opening the database read-only ensures AiMessage cannot corrupt chat.db. WAL mode means reads do not block writes from Messages.app, and writes from Messages.app do not block reads.
Sending via AppleScript
AiMessage sends messages by invoking osascript with an AppleScript that controls Messages.app:
tell application "Messages"
send "Hello" to buddy "+15551234567" of (first service whose service type = iMessage)
end tell
Environment variable safety
The message body and recipient are passed to the script via environment variables rather than interpolated directly into the script string. This prevents injection — if a message body contained AppleScript syntax characters, direct interpolation could produce unexpected behavior or errors. Reading from environment variables makes the boundary between code and data explicit.
Attachments
To send an attachment, osascript is called with a script that uses send with a POSIX file reference. The file must exist on the local filesystem at the path provided.
Event System
The Event enum
All real-time notifications are modeled as variants of a single Event enum defined in src/core_layer/types.rs. Every event flows through the broadcast channel from the iMessage layer to all consumers (webhook dispatcher and WebSocket clients).
#![allow(unused)]
fn main() {
pub enum Event {
MessageReceived(Message),
MessageSent(Message),
ReactionAdded(Reaction),
ReactionRemoved(Reaction),
}
}
When serialized for delivery (webhook payloads, WebSocket frames), events use a type string field:
| Enum variant | type string |
|---|---|
MessageReceived | "message.received" |
MessageSent | "message.sent" |
ReactionAdded | "reaction.added" |
ReactionRemoved | "reaction.removed" |
Event types
message.received
Fired when an incoming message (not sent by the local account) is detected in chat.db.
message.sent
Fired when an outgoing message appears in chat.db. This happens after Messages.app confirms the send — typically within a second of the send_message API call completing.
reaction.added
Fired when a Tapback reaction is added to any message in a conversation. The data payload includes the reaction type and the GUID of the message being reacted to.
reaction.removed
Fired when a previously added Tapback reaction is removed.
How reactions are stored in chat.db
Reactions are not stored as a separate table in chat.db. They appear as ordinary message rows with special values in the associated_message_type and associated_message_guid columns.
associated_message_type mapping
| Value range | Meaning |
|---|---|
2000 | Heart (love) — added |
2001 | Thumbs up — added |
2002 | Thumbs down — added |
2003 | Ha ha — added |
2004 | Exclamation — added |
2005 | Question mark — added |
3000 | Heart (love) — removed |
3001 | Thumbs up — removed |
3002 | Thumbs down — removed |
3003 | Ha ha — removed |
3004 | Exclamation — removed |
3005 | Question mark — removed |
Values in the 2000–2005 range indicate a reaction being added; values in 3000–3005 indicate removal. The associated_message_guid column holds the GUID of the message that was reacted to.
AiMessage’s poller inspects associated_message_type on every new message row. Rows with a non-zero value in this column are classified as reactions and published as ReactionAdded or ReactionRemoved events rather than message events.
Building from Source
Debug build
cargo build
Output: target/debug/aimessage
Release build
cargo build --release
Output: target/release/aimessage
Run directly
cargo run
This requires Full Disk Access to be granted to your terminal emulator (not just the app bundle). Suitable for development; use the app bundle for regular use.
Debug logging
AiMessage uses the tracing crate. Set RUST_LOG to control log output:
# Structured JSON logs for the aimessage crate only
RUST_LOG=aimessage=debug cargo run
# All crates at debug level
RUST_LOG=debug cargo run
# Trace-level (very verbose)
RUST_LOG=aimessage=trace cargo run
Linting
cargo clippy
Fix all warnings before submitting a pull request. The CI enforces a clean clippy run.
Build the app bundle
bash scripts/build-app.sh
This compiles a release build and packages it into bundle/AiMessage.app. Run this whenever you want to test with the proper macOS permissions setup, or to produce a distributable bundle.
Running tests
cargo test
There are 16 unit tests covering core logic. No special setup is required — the tests do not need a running Messages.app or database access.
Project Structure
src/
├── main.rs # Entry point, wiring
├── config.rs # TOML config, auto-generation
├── api/ # HTTP layer (Axum)
│ ├── auth.rs # X-API-Key middleware
│ ├── handlers.rs # Request handlers
│ ├── routes.rs # Router definition
│ └── types.rs # Request/response DTOs
├── core_layer/ # Domain logic
│ ├── types.rs # Message, Conversation, Event, Reaction
│ ├── backend.rs # MessageBackend trait
│ ├── webhook.rs # Webhook dispatcher with retry
│ └── errors.rs # Error types → HTTP status mapping
├── imessage/ # iMessage integration
│ ├── chatdb.rs # chat.db SQLite reader + poller
│ ├── applescript.rs # osascript message sender
│ └── backend.rs # MessageBackend implementation
└── storage/
└── sqlite.rs # App DB: webhooks, message log, state
Module descriptions
main.rs
Reads config, initializes the storage layer, constructs the iMessage backend, starts the polling task, sets up the broadcast channel, wires up the webhook dispatcher task, and starts the Axum HTTP server. All the pieces come together here.
config.rs
Handles loading ~/.aimessage/config.toml, auto-generating it with a random API key on first run, and exiting with a prompt if the file was just created. Uses the toml and serde crates.
api/auth.rs
Axum middleware extractor that reads the X-API-Key header and returns 401 if it is missing or does not match the configured key.
api/handlers.rs
One function per route. Handlers receive validated request types, call into the MessageBackend trait, and return serialized response types.
api/routes.rs
Defines the Axum Router, attaches handlers to paths, and applies the auth middleware to protected routes.
api/types.rs
serde-derived structs for request bodies and response payloads. Kept separate from domain types so the API shape can evolve independently.
core_layer/types.rs
Core domain types: Message, Conversation, Reaction, and the Event enum. These are the shared language between layers.
core_layer/backend.rs
The MessageBackend trait defines the interface the API layer uses to interact with iMessage: send_message, list_messages, get_message, list_conversations, get_conversation. The iMessage layer provides the implementation; this trait makes the API layer testable without a real Mac.
core_layer/webhook.rs
Spawns a task that subscribes to the broadcast channel, receives events, and delivers them to all registered webhook URLs. Implements the retry logic (3 attempts, 1s and 5s backoff).
core_layer/errors.rs
Defines the application error type and its IntoResponse implementation, mapping error variants to appropriate HTTP status codes.
imessage/chatdb.rs
Opens chat.db read-only in WAL mode. Contains the SQL queries for reading messages, conversations, and reactions. Runs the polling loop, tracks the last processed ROWID, and publishes events to the broadcast channel.
imessage/applescript.rs
Constructs and runs osascript commands to send messages and attachments via Messages.app. Uses environment variables to pass message content safely.
imessage/backend.rs
Implements the MessageBackend trait using chatdb.rs and applescript.rs. This is the concrete implementation that runs in production.
storage/sqlite.rs
Manages ~/.aimessage/aimessage.db using SQLite. Stores: registered webhooks (URL, events, secret), the last processed ROWID for resuming after restart.
Contributing
Getting started
- Fork the repository and clone your fork.
- Create a branch for your change.
- Make your changes, run
cargo clippyandcargo test. - Open a pull request against
main.
Code style
Follow the patterns already established in the codebase:
- Keep layers separate. The API layer should not import from
imessage/. Everything goes through theMessageBackendtrait. - Use the existing error type. Add new variants to
core_layer/errors.rsrather than introducing new error types. - No
unwrap()in production paths. Use?or explicit error handling. - Run
cargo clippybefore pushing. CI will fail on clippy warnings. - Format with
cargo fmtbefore pushing.
Where to find issues
Check the GitHub issues list for open bugs and feature requests. Issues tagged good first issue are a good starting point.
Testing
Unit tests live alongside the code they test. Integration tests that require a real Mac (Full Disk Access, Messages.app) should be marked with #[ignore] so they do not run in CI automatically.
cargo test # run all non-ignored tests
cargo test -- --ignored # run ignored (integration) tests on a Mac with permissions