19 KiB
sendmsg
Universal message sender for Signal and SMS/iMessage.
sendmsg is a command-line tool for sending messages via the Signal REST API or locally via macOS Messages (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
- Dependencies
- Installation
- Usage
- CSV Format
- Configuration
- Examples
- Error Handling
- Summary Output
- 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 |
sendmsguses 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
--smspath only.
Installation
- Place the script:
cp sendmsg /usr/local/bin/sendmsg
chmod +x /usr/local/bin/sendmsg
-
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 to reverse proxy your docker compose services and 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:
- $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 icon i.e https://s.gravatar.com/avatar/1234567890
- $NAME - The name you give to this service in traefik
- $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 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.
b. 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
- 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--voicewith--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/-yto skip the prompt for unattended runs, or--dry-runto 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
signalrows may be attachment-only or voice-only: if afileorvoiceis present, themessagemay be left blank.smsrows always require amessage.- The
voicecolumn is Signal only. Ansmsrow with avoicevalue 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
voiceandfile, they are delivered as separate messages. - Recognized voice formats:
.m4a,.aac,.ogg,.opus,.mp3,.wav. smsrows require aserviceofimessageorsms; rows with a missing or invalid service are skipped and reported.- Group recipients are auto-detected: any
recipientthat 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
messagefield 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
voicecolumn is Signal only and may be used with or withoutmessagetext.
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
methodvalues are skipped and reported in the summary. - Missing SMS service:
smsrows without a validservice(imessageorsms) are skipped and reported. - Voice on SMS:
smsrows that specify avoicefile are skipped and reported, since voice notes are Signal-only. - Empty message: Rows without a
messageare skipped and reported — exceptsignalrows 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
1if any rows fail,0on 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
1only when something actually failed to send. Add--jsonfor a machine-readable summary in unattended runs.
Changelog
See CHANGELOG.md for the full version history.
For support or issues, see https://xkcd.com/627/