Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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 osascript controlling 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:

  1. Runs cargo build --release
  2. Creates the bundle/AiMessage.app/Contents/MacOS/ directory structure
  3. Copies the compiled binary into the bundle
  4. 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

PermissionRequired forHow to grant
Full Disk AccessReading chat.dbSystem Settings → Privacy & Security → Full Disk Access → add AiMessage.app
AutomationSending messages via AppleScriptPrompted 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:

  1. Open System SettingsPrivacy & SecurityFull Disk Access
  2. Click the + button
  3. Navigate to bundle/AiMessage.app and add it
  4. 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:

  1. Open System SettingsPrivacy & SecurityAutomation
  2. Find AiMessage in the list
  3. 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]

FieldDefaultDescription
host"127.0.0.1"Bind address. Defaults to localhost only. Use "0.0.0.0" to accept connections from the network.
port3001TCP port for the HTTP server.

[auth]

FieldDescription
api_keyA UUID generated on first run. Required on all API requests as the X-API-Key header. Change this to any string you prefer.

[imessage]

FieldDefaultDescription
chat_db_pathAuto-detectedPath to chat.db. Auto-detected as ~/Library/Messages/chat.db. Override only if your database is in a non-standard location.
poll_interval_ms1000Polling 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

  1. First launch: Config is generated at ~/.aimessage/config.toml and the process exits. Check the generated file.
  2. 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.
  3. 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

ParameterTypeDefaultDescription
conversation_idstringFilter by conversation GUID (e.g. iMessage;-;+15551234567).
sincestringISO 8601 timestamp. Returns messages after this time.
limitinteger50Number of messages to return. Maximum 200.
offsetinteger0Pagination 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 number
  • iMessage;-;user@example.com — iMessage with an email address
  • SMS;-;+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

ParameterTypeDefaultDescription
limitinteger50Number of conversations to return.
offsetinteger0Pagination 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

EventWhen it fires
message.receivedAn incoming message is detected in chat.db
message.sentAn outgoing message sent by AiMessage is confirmed in chat.db
reaction.addedA reaction is added to a message
reaction.removedA 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

FieldTypeDescription
statusstringAlways "ok" when the server is running.
backend.connectedbooleantrue if AiMessage has successfully opened chat.db. false indicates a permissions problem — check Full Disk Access.
backend.messagestring or nullOptional 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)

  1. The iMessage layer polls chat.db every poll_interval_ms milliseconds, comparing the current max ROWID against the last processed ROWID.
  2. New rows are read, parsed into Message or Reaction domain types, and wrapped in Event variants.
  3. Events are published to a tokio::sync::broadcast channel.
  4. 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)

  1. An API request hits POST /api/v1/messages.
  2. The handler calls the MessageBackend trait method send_message.
  3. The iMessage layer implementation invokes osascript with the recipient and body.
  4. Messages.app sends the message. The sent message eventually appears in chat.db and is picked up by the polling loop as a message.sent event.

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:

DatabaseLocationContents
chat.db~/Library/Messages/chat.dbiMessage data — read-only
aimessage.db~/.aimessage/aimessage.dbApp 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:

  1. Query chat.db for rows with ROWID > last_processed_rowid.
  2. Parse each new row into a domain type.
  3. Publish events to the broadcast channel.
  4. Update last_processed_rowid in aimessage.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 varianttype 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 rangeMeaning
2000Heart (love) — added
2001Thumbs up — added
2002Thumbs down — added
2003Ha ha — added
2004Exclamation — added
2005Question mark — added
3000Heart (love) — removed
3001Thumbs up — removed
3002Thumbs down — removed
3003Ha ha — removed
3004Exclamation — removed
3005Question 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

  1. Fork the repository and clone your fork.
  2. Create a branch for your change.
  3. Make your changes, run cargo clippy and cargo test.
  4. 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 the MessageBackend trait.
  • Use the existing error type. Add new variants to core_layer/errors.rs rather than introducing new error types.
  • No unwrap() in production paths. Use ? or explicit error handling.
  • Run cargo clippy before pushing. CI will fail on clippy warnings.
  • Format with cargo fmt before 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