Universal message sender for Signal and SMS/iMessage
Go to file
Daniel Spisak 6db49e60e0
Merge pull request #8 from docwho76/v5.1.1-doc-fixes
fix readme formatting errors
2026-06-13 01:48:42 -07:00
.gitignore commit first release files and docs 2026-06-08 03:41:28 -07:00
CHANGELOG.md bump minor version for doc updates only 2026-06-13 01:37:46 -07:00
LICENSE Initial commit 2026-06-08 00:18:48 -07:00
README.md fix readme formatting errors 2026-06-13 01:47:59 -07:00
sendmsg bump minor version for doc updates only 2026-06-13 01:37:46 -07:00

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

┌─────────────────────────────────────────────────────────┐
│                      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
  1. 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:
      - $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 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 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
  1. 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 for the full version history.


For support or issues, see https://xkcd.com/627/