sendmsg/README.md
2026-06-13 01:47:59 -07:00

497 lines
19 KiB
Markdown

# sendmsg
**Universal message sender for Signal and SMS/iMessage.**
`sendmsg` is a command-line tool for sending messages via the [Signal REST API](https://github.com/bbernhard/signal-cli-rest-api) or locally via macOS [Messages](https://apps.apple.com/app/messages/id1092291483) (iMessage/SMS) through the [`imsg`] CLI.
It supports individual direct messages, Signal group broadcasts, file attachments, Signal voice messages, and bulk sending from CSV files — all with a single, consistent interface.
---
## Table of Contents
- [Architecture](#architecture)
- [Dependencies](#dependencies)
- [Installation](#installation)
- [Usage](#usage)
* [Signal Messages](#signal-messages)
* [Voice Messages](#voice-messages)
* [SMS / iMessage](#sms--imessage)
* [Bulk Send from CSV](#bulk-send-from-csv)
* [Management Commands](#management-commands)
- [CSV Format](#csv-format)
- [Configuration](#configuration)
- [Examples](#examples)
- [Error Handling](#error-handling)
- [Summary Output](#summary-output)
- [Changelog](#changelog)
---
## Architecture
```
┌─────────────────────────────────────────────────────────┐
│ sendmsg (CLI) │
│ Python 3 script with argparse-based argument parsing │
├─────────────────────────────────────────────────────────┤
│ │
│ ┌──────────────┐ ┌──────────────────────────┐ │
│ │ --signal │ │ --sms │ │
│ │ │ │ │ │
│ │ POST JSON │ │ Call `imsg send` CLI │ │
│ │ to Signal │ │ │ │
│ │ REST API │ │ │ │
│ └──────┬───────┘ └────────────┬─────────────┘ │
│ │ │ │
│ ▼ │ │
│ (Docker) ▼ │
│ ┌───────────────────────┐ ┌─────────────────────┐ │
│ │ signal-cli-rest-api │ │ │ │
│ │ http://localhost:8080 │ │ macOS Messages │ │
│ │ (or SIGNAL_REST_URL) │ │ │ │
│ └───────────────────────┘ └─────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────┘
```
**Signal path:** `sendmsg` → JSON HTTP POST → `signal-cli-rest-api` → Docker container → Signal network. Attachments are base64-encoded inline in the JSON request body (the API does not accept multipart uploads). Voice messages are sent the same way with the API's `voice` flag set so they render as playable voice notes.
**SMS/iMessage path:** `sendmsg` → subprocess call → `imsg` CLI → macOS Messages framework → carrier/Apple.
---
## Dependencies
### Required
| Dependency | Version | Purpose |
| ---------- | ------- | -------------- |
| Python 3 | 3.9+ | Script runtime |
> `sendmsg` uses only the Python standard library — no third-party packages to install.
### Signal (optional — only needed for `--signal`)
| Dependency | Version | Purpose |
| ---------------------------- | ------- | ------------------------------ |
| Docker | 24+ | Container runtime |
| `signal-cli-rest-api` | latest | Signal REST API server |
| `signal-cli` (via container) | latest | Signal protocol implementation |
> Signal sends only need network access to the REST API endpoint — they do
> not require macOS.
### SMS/iMessage (optional — only needed for `--sms`)
| Dependency | Version | Purpose |
| ---------- | ----------------- | --------------------------- |
| macOS 12+ | Monterey or later | Native Messages app |
| `imsg` CLI | latest | macOS Messages / SMS bridge |
> SMS/iMessage uses the native macOS Messages app, so macOS 12+ is required
> for the `--sms` path only.
---
## Installation
1. **Place the script:**
```
cp sendmsg /usr/local/bin/sendmsg
chmod +x /usr/local/bin/sendmsg
```
2. **Ensure dependencies are installed:**
**For Signal:**
a. **Get signal-cli-rest running in docker compose:**
Put the following config into your docker-compose.yml file (this assumes you are using [traefik](https://github.com/traefik/traefik) to reverse proxy your docker compose services and [pullio](https://hotio.dev/scripts/pullio/) to automate updates)
```
services:
signal-api:
image: bbernhard/signal-cli-rest-api:latest
restart: always
expose:
- "8080"
labels:
- "traefik.http.routers.$NAME.rule=Host(`$SIGNAL_INTERNAL_URL`)"
- "traefik.http.routers.$NAME.entrypoints=websecure"
- "traefik.http.routers.$NAME.tls.certresolver=route53"
- "org.hotio.pullio.notify=true"
- "org.hotio.pullio.update=true"
- "org.hotio.pullio.generic.webhook=$WEBHOOK_URL"
- "org.hotio.pullio.author.avatar=$GRAVATAR_URL"
volumes:
- $PATH_TO_DOCKER_STORAGE_DIR:/home/.local/share/signal-cli
environment:
- MODE=native
- AUTO_RECEIVE_SCHEDULE=0 22 * * *
- PUID=$USER_ID
- PGID=$GROUP_ID
- TZ=$TIMEZONE_NAME
```
$SIGNAL_INTERNAL_URL - The internal URL traefik will server the container from https://example.internal.mydomain.com
$WEBHOOK_URL - The URL to your IFTTT webhook for Pullio, i.e https://maker.ifttt.com/trigger/Pullio/with/key/test-key
$GRAVATAR_URL - The URL for your [Gravatar](https://gravatar.com/) icon i.e https://s.gravatar.com/avatar/1234567890
$NAME - The name you give to this service in traefik
$PATH_TO_DOCKER_STORAGE_DIR - The directory on your docker compose host where the signal containers filesystem lives i.e /opt/docker-data/signal
$TIMEZONE_NAME - The [Linux Timezone](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones) name i.e "America/Los_Angeles"
Note: See https://builder.aws.com/content/2tmBo5uCgpwQegRBsdSDmupj5rH/lets-encrypt-dns01-challenge-with-traefik-and-aws-route-53 for examples of using AWS Route53 with traefik to do Lets Encrypt ACME DNS-01 requests for automatic SSL certificate creation with Traefik. Installation of traefik is outside the scope of this document.
```
# Verify signal-cli-rest-api is running
curl -s http://localhost:8080/v1/accounts | python3 -m json.tool
```
**For SMS/iMessage:**
```
# Verify imsg is installed and available
imsg send --help
```
3. **Test the script:**
```
sendmsg --help
sendmsg --show-config # confirm where settings are being read from
```
---
## Usage
```
sendmsg [OPTIONS]
Messaging methods (choose one):
--signal Send via Signal
--sms Send via SMS/iMessage (macOS Messages)
--csv FILE Bulk send from a CSV file
Management commands (choose one):
--list-signal List linked Signal accounts
--link-signal Link a new device to your Signal account
--show-config Print resolved configuration and where each value came from
```
### Signal Messages
```
# Send a text message
sendmsg --signal --to +18885551212 --text "Hello from Signal!"
# Send to a group
sendmsg --signal --recipients group.TestGroupHash --text "Hello everyone!"
# Send with an attachment
sendmsg --signal --to +18885551212 --text "Check this out" --attach ~/photo.jpg
# Send multiple messages (concatenated with newlines)
sendmsg --signal --to +18885551212 --text "Line 1" --text "Line 2"
# Send multiple attachments
sendmsg --signal --to +18885551212 --text "Files" --attach ~/doc.pdf ~/pic.jpg
# Send an attachment with no text
sendmsg --signal --to +18885551212 --attach ~/report.pdf
```
> Attachments are read, base64-encoded, and sent inside the JSON request to
> the Signal REST API. The original filename and detected MIME type are
> preserved so recipients see the correct file name and type. Attachments
> larger than 100 MB are rejected with a warning.
### Voice Messages
Send an audio file as a Signal **voice note** (a playable voice message,
not a file attachment) with `--voice`:
```
# Send a voice message
sendmsg --signal --to +18885551212 --voice ~/note.m4a
# Send a voice message to a group
sendmsg --signal --recipients group.TestGroupHash --voice ~/briefing.ogg
# Voice message with accompanying text
sendmsg --signal --to +18885551212 --voice ~/note.m4a --text "Listen to this"
```
> Recognized audio formats: `.m4a`, `.aac`, `.ogg`, `.opus`, `.mp3`, `.wav`.
> A voice note is sent as its own message; if you combine `--voice` with
> `--attach`, the voice note and the file attachments are delivered as
> separate messages.
### SMS / iMessage
The `--service` flag is **required** and must be either `imessage` or `sms`.
```
# Send via iMessage
sendmsg --sms --to +18885551212 --text "Hello via iMessage!" --service imessage
# Send via SMS
sendmsg --sms --to +18885551212 --text "Hello via SMS!" --service sms
# Send with an attachment
sendmsg --sms --to +18885551212 --text "Photo attached" --service imessage --file ~/photo.jpg
```
> SMS/iMessage supports a single attachment per message; if multiple files
> are supplied, the first valid one is used and the rest are skipped with a
> warning.
### Bulk Send from CSV
```
sendmsg --csv messages.csv # Send all rows
sendmsg --csv messages.csv --delay 2 # Wait 2 seconds between every send
sendmsg --csv messages.csv --dry-run # Preview every row without sending
sendmsg --csv messages.csv --yes # Skip the large-batch confirmation
```
> Batches of more than 50 rows prompt for confirmation before sending. Use
> `--yes` / `-y` to skip the prompt for unattended runs, or `--dry-run` to
> preview exactly what would be sent first.
### Management Commands
```
# List linked Signal accounts
sendmsg --list-signal
# Link a new device
sendmsg --link-signal
sendmsg --link-signal --name "my-laptop"
# Show the active configuration and its sources
sendmsg --show-config
sendmsg --show-config -v # also checks whether the Signal REST API is reachable
```
---
## CSV Format
Create a CSV file with the following columns:
| Column | Required | Description |
| ----------- | -------- | ------------------------------------------------------------------------------------ |
| `method` | Yes | `signal` or `sms` (defaults to `signal` if left blank) |
| `recipient` | Yes | Phone number, or a Signal group ID (`group.XXXX` or a raw group key) |
| `name` | No | Display name shown during status output (e.g., "Alice", "Marketing Group") |
| `message` | Varies | Message text. Required for `sms` rows and for `signal` rows without a `file`/`voice`. |
| `account` | No | Signal account phone number (defaults to `$SIGNAL_ACCOUNT` / config value) |
| `service` | Varies | SMS service: `imessage` or `sms`. **Required** on `sms` rows; ignored for `signal`. |
| `file` | No | Path to an attachment file. `~` and environment variables are expanded. |
| `voice` | No | Path to an audio file to send as a Signal **voice note**. Signal only. |
| `delay` | No | Seconds to wait after this row is sent (overrides the global `--delay`). |
**Notes**
- `signal` rows may be **attachment-only** or **voice-only**: if a `file` or `voice` is present, the `message` may be left blank. `sms` rows always require a `message`.
- The `voice` column is **Signal only**. An `sms` row with a `voice` value is skipped and reported, since SMS/iMessage has no voice-note concept.
- A voice note is sent as its own message; if a row has both `voice` and `file`, they are delivered as separate messages.
- Recognized voice formats: `.m4a`, `.aac`, `.ogg`, `.opus`, `.mp3`, `.wav`.
- `sms` rows require a `service` of `imessage` or `sms`; rows with a missing or invalid service are skipped and reported.
- Group recipients are auto-detected: any `recipient` that is not a phone number is treated as a Signal group ID. Bare (no `+`) phone numbers are recognized as numbers, not groups.
- File paths starting with `~` are expanded to the home directory of the user running the script. If a named attachment cannot be found, a warning is printed and the message is still sent without it.
### Example CSV
```
method,recipient,name,message,account,service,file,voice,delay
signal,+18885551111,Alice,Hello via Signal,+18885551111,,,,
signal,group.ZzBHd3NZO...,Team Alert,Morning update for the team,,,,,
signal,+18885552222,Report,,,,~/report.pdf,,
signal,+18885556666,Briefing,,,,,~/briefing.m4a,
sms,+18885553333,Bob,SMS test,,imessage,~/pic.jpg,,
signal,+18885554444,Carol,With a delay after this row,,,,,3
```
### Status Output
During a bulk send, each row prints its name (if provided):
```
[1/5] SIGNAL → Alice (+18885551111)
[2/5] SIGNAL → Team Alert (group.ZzBHd3NZO...)
[3/5] SIGNAL → Report (+18885552222)
[4/5] SMS → Bob (+18885553333)
[5/5] SIGNAL → Carol (+18885554444)
```
If no `name` is provided, the recipient is shown instead. A `--dry-run`
prefixes each line with `[DRY]` and notes group/file/service details
without sending.
---
## Configuration
Settings are read from a config file at `~/.sendmsg.conf`, with environment
variable and built-in fallbacks.
**Resolution order:** environment variable → config file → built-in default.
### Config file format (INI-style)
```
[settings]
signal_rest_url = http://localhost:8080
signal_default_account = +1234567890
```
### Environment Variables
| Variable | Default | Description |
| ----------------- | ----------------------- | ----------------------------------- |
| `SIGNAL_REST_URL` | `http://localhost:8080` | URL of the Signal REST API server |
| `SIGNAL_ACCOUNT` | `+1234567890` | Default Signal account phone number |
### Inspecting the active configuration
Use `--show-config` to print the resolved values and exactly where each one
came from (environment, config file, or built-in default):
```
$ sendmsg --show-config
==================================================
⚙️ sendmsg configuration
==================================================
Version: 5.1.0
Config file: /Users/you/.sendmsg.conf (found)
signal_rest_url: http://localhost:8080
└─ source: config (/Users/you/.sendmsg.conf)
signal_default_account: +18885551212
└─ source: env ($SIGNAL_ACCOUNT)
Resolution order: env var > config file > built-in default
==================================================
```
Add `-v` to also probe whether the Signal REST API is currently reachable.
### Override Example
```
export SIGNAL_REST_URL=http://localhost:8082
export SIGNAL_ACCOUNT=+18885551212
sendmsg --csv messages.csv
```
---
## Examples
### Daily Broadcast
```
method,recipient,name,message,account,service,file,voice,delay
signal,group.ZzBHd3NZO...,Daily Update,Good morning team! Here's your daily briefing.,+18002222222,,,,
```
### Personalized Outreach
```
method,recipient,name,message,account,service,file,voice,delay
signal,+18885551111,John,Hi John hope you're doing well,,,,,
sms,+18885552222,Jane,Hey Jane just checking in,,imessage,,,
signal,+18885553333,Alex,Alex don't forget the meeting tomorrow at 3pm,,,,,
```
> Avoid commas inside the `message` field unless the field is quoted, since
> commas are the CSV column separator.
### With Attachments
```
method,recipient,name,message,account,service,file,voice,delay
signal,+18885551111,John,Here's the report you asked for,+18002222222,,~/Downloads/report.pdf,,
sms,+18885552222,Jane,Photo from the event,,imessage,~/Photos/event.jpg,,
```
### With Voice Messages
```
method,recipient,name,message,account,service,file,voice,delay
signal,+18885551111,John,Listen to this update,,,,~/recordings/update.m4a,
signal,group.ZzBHd3NZO...,Team,,,,,~/recordings/standup.ogg,
```
> The `voice` column is Signal only and may be used with or without
> `message` text.
### With Delays
```
method,recipient,name,message,account,service,file,voice,delay
signal,+18885551111,Alice,First message,,,,,
signal,+18885552222,Bob,Second message after a 3s pause,,,,,3
signal,+18885553333,Charlie,Third message,,,,,
```
A per-row `delay` value takes precedence over the global `--delay` flag for
that row.
---
## Error Handling
- **Unknown method:** Rows with invalid `method` values are skipped and reported in the summary.
- **Missing SMS service:** `sms` rows without a valid `service` (`imessage` or `sms`) are skipped and reported.
- **Voice on SMS:** `sms` rows that specify a `voice` file are skipped and reported, since voice notes are Signal-only.
- **Empty message:** Rows without a `message` are skipped and reported — except `signal` rows that carry an attachment or a voice note, which are allowed.
- **Missing attachment:** If a named file cannot be found, a warning is printed and the message is sent without the attachment.
- **Oversized attachment:** Files larger than 100 MB are rejected with a warning before sending.
- **Transient REST failures:** Rate-limit and server errors (HTTP 429/5xx) and network blips are retried up to 3 times with backoff.
- **Failed sends:** Failed attempts are counted and listed in the summary.
- **Exit codes:** The script exits with `1` if any rows fail, `0` on full success.
---
## Summary Output
After processing all rows, a summary is printed:
```
==================================================
📊 CSV Send Summary
==================================================
Total rows: 5
✅ Success: 3
⏭️ Skipped: 1
❌ Failed: 1
Notes:
• Row 4: invalid/missing SMS service
• Row 5: failed to send
==================================================
```
> Intentional skips — unknown method, missing/invalid SMS service, voice on
> an SMS row, and empty messages — are counted under **Skipped**, not
> **Failed**. Only genuine send failures count as **Failed**, and the exit
> code is `1` only when something actually failed to send. Add `--json` for a
> machine-readable summary in unattended runs.
---
## Changelog
See [CHANGELOG.md](CHANGELOG.md) for the full version history.
---
*For support or issues, see <https://xkcd.com/627/>*