messengerr 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +77 -0
- data/exe/messengerr +5 -0
- data/lib/messengerr/cli/credentials_command.rb +73 -0
- data/lib/messengerr/cli/integrations_command.rb +27 -0
- data/lib/messengerr/cli/send_command.rb +373 -0
- data/lib/messengerr/cli.rb +50 -0
- data/lib/messengerr/contact_validator.rb +48 -0
- data/lib/messengerr/credentials.rb +117 -0
- data/lib/messengerr/crypto.rb +39 -0
- data/lib/messengerr/csv_source.rb +31 -0
- data/lib/messengerr/delivery.rb +15 -0
- data/lib/messengerr/errors.rb +10 -0
- data/lib/messengerr/integrations/base.rb +59 -0
- data/lib/messengerr/integrations/clicksend.rb +92 -0
- data/lib/messengerr/integrations/postmark.rb +77 -0
- data/lib/messengerr/integrations.rb +5 -0
- data/lib/messengerr/ledger.rb +78 -0
- data/lib/messengerr/paths.rb +19 -0
- data/lib/messengerr/placeholder.rb +10 -0
- data/lib/messengerr/prompter.rb +59 -0
- data/lib/messengerr/recipient.rb +4 -0
- data/lib/messengerr/registry.rb +49 -0
- data/lib/messengerr/renderer.rb +30 -0
- data/lib/messengerr/report.rb +24 -0
- data/lib/messengerr/result.rb +12 -0
- data/lib/messengerr/sender.rb +104 -0
- data/lib/messengerr/sending_options/base.rb +45 -0
- data/lib/messengerr/sending_options/clicksend.rb +18 -0
- data/lib/messengerr/sending_options/postmark.rb +12 -0
- data/lib/messengerr/sending_options.rb +5 -0
- data/lib/messengerr/setups/base.rb +80 -0
- data/lib/messengerr/setups/clicksend.rb +13 -0
- data/lib/messengerr/setups/postmark.rb +9 -0
- data/lib/messengerr/setups.rb +5 -0
- data/lib/messengerr/version.rb +3 -0
- data/lib/messengerr.rb +22 -0
- metadata +201 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: c1c93598f3e402b4a4096677f451d9b4116c2378d5fb1466405a0ba0d436802a
|
|
4
|
+
data.tar.gz: 58064c3aa1e92ec5812a301d1cf4be8191fac258abb2d1bfad65c9644ef5f9c3
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: c11c74416f64968164c0de51700f0e52906affcf36cd05f21965b116ed90c83db89835a5c2c0223d9d81240ebadbc235dd343fbfae0624561b3660e0396a48e8
|
|
7
|
+
data.tar.gz: 4b32107a5ffe04d44101b29818af58e5ab5cf0a637c71c355ee7c73229941cde1a08d83ab19d2b19a026d99eb4f3e422916ef9720646bead74142b17701ab5ca
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Daiwick
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
<p align="center">
|
|
2
|
+
<picture>
|
|
3
|
+
<source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/jalappeno-apps/messengerr/main/docs/assets/images/logo-lockup-dark.svg">
|
|
4
|
+
<img alt="messengerr" src="https://raw.githubusercontent.com/jalappeno-apps/messengerr/main/docs/assets/images/logo-lockup.svg" height="56">
|
|
5
|
+
</picture>
|
|
6
|
+
</p>
|
|
7
|
+
|
|
8
|
+
<p align="center"><b>Send templated email and SMS to a CSV contact list, safely, from your terminal.</b></p>
|
|
9
|
+
|
|
10
|
+
Write one message with `{{placeholders}}`, point messengerr at a CSV, and it validates the contacts, shows you exactly what will go out, sends through your provider, and keeps a record of every result.
|
|
11
|
+
|
|
12
|
+
**Documentation: [jalappeno-apps.github.io/messengerr](https://jalappeno-apps.github.io/messengerr/)**
|
|
13
|
+
|
|
14
|
+
## Install
|
|
15
|
+
|
|
16
|
+
Requires Ruby 3.2 or newer.
|
|
17
|
+
|
|
18
|
+
```sh
|
|
19
|
+
gem install messengerr
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
## Quick start
|
|
23
|
+
|
|
24
|
+
```sh
|
|
25
|
+
messengerr credentials setup postmark # or clicksend; keys are verified live, then stored encrypted
|
|
26
|
+
messengerr send --dry-run # walk the wizard and preview, sending nothing
|
|
27
|
+
messengerr send # preview, confirm, send
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Given a CSV like
|
|
31
|
+
|
|
32
|
+
```text
|
|
33
|
+
name,email,plan
|
|
34
|
+
Ann,ann@example.com,Pro
|
|
35
|
+
Bob,bob@example.com,Team
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
and the message `Hi {{name}}, your {{plan}} invoice is ready.`, the preview shows:
|
|
39
|
+
|
|
40
|
+
```text
|
|
41
|
+
--- Preview (2 messages) ---
|
|
42
|
+
To: ann@example.com
|
|
43
|
+
Subject: Invoice for Ann
|
|
44
|
+
Body: Hi Ann, your Pro invoice is ready.
|
|
45
|
+
...
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## Why it is safe to point at a real list
|
|
49
|
+
|
|
50
|
+
- **Preview first.** You see the count and the first rendered messages, then confirm. `--dry-run` sends nothing.
|
|
51
|
+
- **Bad data is surfaced, not sent.** Invalid contacts are listed by row. A malformed or empty placeholder aborts the run instead of going out as literal text.
|
|
52
|
+
- **See the real thing.** HTML emails (`--html letter.html`) are written to a preview file you can open in a browser before you confirm. Values from the CSV are HTML-escaped.
|
|
53
|
+
- **A record of every result.** Each outcome is appended to a ledger as it happens, so a crash or Ctrl-C never loses track of who was messaged.
|
|
54
|
+
- **Resume without guessing.** `--resume` skips recipients already sent. Failures that might already have been delivered are marked *uncertain* and flagged before you retry.
|
|
55
|
+
|
|
56
|
+
## Integrations
|
|
57
|
+
|
|
58
|
+
| Integration | Delivers |
|
|
59
|
+
|---|---|
|
|
60
|
+
| Postmark | email |
|
|
61
|
+
| ClickSend | SMS |
|
|
62
|
+
|
|
63
|
+
New providers follow a small contract. See [Writing an integration](https://jalappeno-apps.github.io/messengerr/writing-an-integration.html).
|
|
64
|
+
|
|
65
|
+
## Good to know
|
|
66
|
+
|
|
67
|
+
messengerr sends plain-text messages (typed as one line, or multi-line from a file) and HTML email from a file, with no attachments, and does not track delivery. It adds no unsubscribe links and does not check consent: laws such as CAN-SPAM, GDPR/PECR and the TCPA are the sender's responsibility. Credentials are stored in an encrypted file under `~/.config/messengerr`, with the key beside it by default, and `gem uninstall` does not remove them. Read [Limitations](https://jalappeno-apps.github.io/messengerr/limitations.html) and [Credentials](https://jalappeno-apps.github.io/messengerr/credentials.html) before a large send.
|
|
68
|
+
|
|
69
|
+
## Contributing
|
|
70
|
+
|
|
71
|
+
Bug reports and pull requests are welcome. Start with [CONTRIBUTING.md](CONTRIBUTING.md) and [STYLE.md](STYLE.md).
|
|
72
|
+
|
|
73
|
+
## License
|
|
74
|
+
|
|
75
|
+
Released under the [MIT License](LICENSE).
|
|
76
|
+
|
|
77
|
+
Maintained by [Daiwick](https://daiwick.com). Questions and enquiries: developers@daiwick.com. Bugs and feature requests: [GitHub issues](https://github.com/jalappeno-apps/messengerr/issues). Security reports: see [SECURITY.md](SECURITY.md).
|
data/exe/messengerr
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
module Messengerr
|
|
2
|
+
class CLI
|
|
3
|
+
# messengerr credentials setup|list|verify|delete [--purge]
|
|
4
|
+
class CredentialsCommand
|
|
5
|
+
def initialize(prompter:, credentials:)
|
|
6
|
+
@prompter = prompter
|
|
7
|
+
@credentials = credentials
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def run(args)
|
|
11
|
+
case args.first
|
|
12
|
+
when "setup" then setup(args[1])
|
|
13
|
+
when "list" then list
|
|
14
|
+
when "verify" then verify(args[1])
|
|
15
|
+
when "delete" then delete(purge: args.include?("--purge"))
|
|
16
|
+
else
|
|
17
|
+
@prompter.say("Usage: messengerr credentials setup|list|verify|delete [--purge]")
|
|
18
|
+
1
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
private
|
|
23
|
+
|
|
24
|
+
def setup(key)
|
|
25
|
+
integration = find(key) or return 1
|
|
26
|
+
integration.setup_class.new(prompter: @prompter, credentials: @credentials).run ? 0 : 1
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def list
|
|
30
|
+
@prompter.say("Registered integrations:")
|
|
31
|
+
Registry.all.each do |integration|
|
|
32
|
+
state = @credentials.configured?(integration.integration_key) ? "configured" : "not configured"
|
|
33
|
+
@prompter.say(" #{integration.integration_key} (#{integration.delivery_method}) — #{state}")
|
|
34
|
+
end
|
|
35
|
+
0
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def verify(key)
|
|
39
|
+
integration = find(key) or return 1
|
|
40
|
+
creds = @credentials.read(integration.integration_key)
|
|
41
|
+
unless creds
|
|
42
|
+
@prompter.say("#{integration.integration_key} is not configured — run `messengerr credentials setup #{integration.integration_key}`")
|
|
43
|
+
return 1
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
integration.new(creds).verify!
|
|
47
|
+
@prompter.say("#{integration.integration_key} verified.")
|
|
48
|
+
0
|
|
49
|
+
rescue IntegrationError => e
|
|
50
|
+
@prompter.say(e.message)
|
|
51
|
+
1
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def delete(purge:)
|
|
55
|
+
target = purge ? "credentials file AND master key" : "credentials file"
|
|
56
|
+
return 1 unless @prompter.confirm("Delete the #{target}?")
|
|
57
|
+
|
|
58
|
+
purge ? @credentials.purge! : @credentials.delete_file!
|
|
59
|
+
@prompter.say("Deleted.")
|
|
60
|
+
0
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def find(key)
|
|
64
|
+
integration = key && Registry.find(key)
|
|
65
|
+
unless integration
|
|
66
|
+
@prompter.say("Unknown integration '#{key}'. Available: #{Registry.all.map(&:integration_key).join(', ')}")
|
|
67
|
+
return nil
|
|
68
|
+
end
|
|
69
|
+
integration
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
module Messengerr
|
|
2
|
+
class CLI
|
|
3
|
+
# messengerr integrations — registry listing by delivery method.
|
|
4
|
+
class IntegrationsCommand
|
|
5
|
+
def initialize(prompter:, credentials:)
|
|
6
|
+
@prompter = prompter
|
|
7
|
+
@credentials = credentials
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def run
|
|
11
|
+
%i[email sms].each do |method|
|
|
12
|
+
@prompter.say("#{method}:")
|
|
13
|
+
integrations = Registry.by_method(method)
|
|
14
|
+
if integrations.empty?
|
|
15
|
+
@prompter.say(" (none registered)")
|
|
16
|
+
else
|
|
17
|
+
integrations.each do |integration|
|
|
18
|
+
state = @credentials.configured?(integration.integration_key) ? "configured" : "not configured"
|
|
19
|
+
@prompter.say(" #{integration.integration_key} — #{state}")
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
0
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -0,0 +1,373 @@
|
|
|
1
|
+
require "optparse"
|
|
2
|
+
require "set"
|
|
3
|
+
|
|
4
|
+
module Messengerr
|
|
5
|
+
class CLI
|
|
6
|
+
# messengerr send [flags] — interactive wizard:
|
|
7
|
+
# delivery method → configured integration → message (with {{placeholders}})
|
|
8
|
+
# → CSV path → map placeholders to columns → contact column → validate
|
|
9
|
+
# (abort or drop defects) → preview → confirm → send → failures listed
|
|
10
|
+
# → retry failed only.
|
|
11
|
+
#
|
|
12
|
+
# Flags: --dry-run, --resume LEDGER, --ledger PATH, --batch-size N,
|
|
13
|
+
# --rate-limit N, --from ADDRESS, --message-file FILE, --html FILE
|
|
14
|
+
class SendCommand
|
|
15
|
+
SMS_SEGMENT = 160
|
|
16
|
+
# Sending options settable from flags (the integrations support more; see docs/integrations.md).
|
|
17
|
+
CLI_OPTIONS = %i[from batch_size rate_limit].freeze
|
|
18
|
+
|
|
19
|
+
def initialize(prompter:, credentials:)
|
|
20
|
+
@prompter = prompter
|
|
21
|
+
@credentials = credentials
|
|
22
|
+
@flags = { ledger: Ledger.default_path }
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def run(args = [])
|
|
26
|
+
parse_flags(args)
|
|
27
|
+
if @flags[:help]
|
|
28
|
+
@prompter.say(@parser.help)
|
|
29
|
+
return 0
|
|
30
|
+
end
|
|
31
|
+
check_resume_ledger!
|
|
32
|
+
@file_message = load_message_file
|
|
33
|
+
|
|
34
|
+
method = pick_delivery_method
|
|
35
|
+
check_html_method!(method)
|
|
36
|
+
integration_class = pick_integration(method) or return 1
|
|
37
|
+
check_html_supported!(integration_class)
|
|
38
|
+
message, subject = collect_message(method)
|
|
39
|
+
source = load_csv or return 1
|
|
40
|
+
mapping = map_placeholders(message, subject, source)
|
|
41
|
+
contact_column = pick_contact_column(method, source)
|
|
42
|
+
outcome = validate_recipients(source, contact_column, method) or return 1
|
|
43
|
+
deliveries = preview_and_confirm(outcome[:valid], message, subject, mapping, method) or return 1
|
|
44
|
+
|
|
45
|
+
send_and_report(integration_class, deliveries, outcome[:defects])
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
private
|
|
49
|
+
|
|
50
|
+
# Bad flags are user errors, not crashes: they surface as Messengerr::Error
|
|
51
|
+
# (printed as "messengerr: ..." with exit 1) before any prompt appears.
|
|
52
|
+
def parse_flags(args)
|
|
53
|
+
@parser = build_parser
|
|
54
|
+
extra = @parser.parse(args)
|
|
55
|
+
raise Error, "unexpected argument: #{extra.join(' ')} (see `messengerr send --help`)" unless extra.empty?
|
|
56
|
+
raise Error, "use either --html or --message-file, not both (see `messengerr send --help`)" if @flags[:html] && @flags[:message_file]
|
|
57
|
+
rescue OptionParser::ParseError => e
|
|
58
|
+
raise Error, "#{e.message} (see `messengerr send --help`)"
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def build_parser
|
|
62
|
+
OptionParser.new do |opts|
|
|
63
|
+
opts.banner = "Usage: messengerr send [flags]"
|
|
64
|
+
opts.on("--dry-run", "Preview the rendered messages; send nothing") { @flags[:dry_run] = true }
|
|
65
|
+
opts.on("--resume LEDGER", "Skip recipients already sent in this ledger (row + contact)") do |v|
|
|
66
|
+
@flags[:resume] = v
|
|
67
|
+
@flags[:ledger] = v
|
|
68
|
+
end
|
|
69
|
+
opts.on("--ledger PATH", "Where to record results (default: ./messengerr-ledger-<time>.jsonl)") { |v| @flags[:ledger] = v }
|
|
70
|
+
opts.on("--batch-size N", Integer, "Messages per batch") { |v| @flags[:batch_size] = positive(v) }
|
|
71
|
+
opts.on("--rate-limit N", Integer, "Batches per second") { |v| @flags[:rate_limit] = positive(v) }
|
|
72
|
+
opts.on("--from ADDRESS", "Override the sender address/ID") { |v| @flags[:from] = v }
|
|
73
|
+
opts.on("--message-file FILE", "Read a multi-line plain-text message from FILE instead of typing one line") { |v| @flags[:message_file] = v }
|
|
74
|
+
opts.on("--html FILE", "Send FILE as an HTML email (email only; HTML only, no text version)") { |v| @flags[:html] = v }
|
|
75
|
+
opts.on("-h", "--help", "Show this help") { @flags[:help] = true }
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def positive(value)
|
|
80
|
+
raise OptionParser::InvalidArgument, "#{value} (must be a positive integer)" unless value.positive?
|
|
81
|
+
|
|
82
|
+
value
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# A mistyped --resume path must not silently mean "nothing sent yet".
|
|
86
|
+
def check_resume_ledger!
|
|
87
|
+
return unless @flags[:resume]
|
|
88
|
+
return if File.file?(@flags[:resume])
|
|
89
|
+
|
|
90
|
+
raise Error, "resume ledger not found: #{@flags[:resume]}"
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# The message from --html / --message-file, normalised, or nil when the
|
|
94
|
+
# message will be typed. Problems surface before any prompt.
|
|
95
|
+
def load_message_file
|
|
96
|
+
path = @flags[:html] || @flags[:message_file]
|
|
97
|
+
return nil unless path
|
|
98
|
+
|
|
99
|
+
expanded = File.expand_path(path)
|
|
100
|
+
raise Error, "message file not found: #{path}" unless File.file?(expanded)
|
|
101
|
+
|
|
102
|
+
text = File.read(expanded, encoding: "bom|utf-8")
|
|
103
|
+
raise Error, "message file is not valid UTF-8: #{path}" unless text.valid_encoding?
|
|
104
|
+
|
|
105
|
+
text = text.gsub("\r\n", "\n").rstrip
|
|
106
|
+
raise Error, "message file is empty: #{path}" if text.empty?
|
|
107
|
+
|
|
108
|
+
text
|
|
109
|
+
rescue SystemCallError => e
|
|
110
|
+
raise Error, "cannot read message file #{path}: #{e.message}"
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def check_html_method!(method)
|
|
114
|
+
raise Error, "--html is only for email; SMS messages are plain text (use --message-file)" if @flags[:html] && method != :email
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def check_html_supported!(integration_class)
|
|
118
|
+
return unless @flags[:html]
|
|
119
|
+
return if integration_class.formats.include?(:html)
|
|
120
|
+
|
|
121
|
+
raise Error, "#{integration_class.integration_key} can't send HTML email"
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def message_format = @flags[:html] ? :html : :text
|
|
125
|
+
|
|
126
|
+
def pick_delivery_method
|
|
127
|
+
@prompter.choose("Delivery method:", %w[email sms]).to_sym
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def pick_integration(method)
|
|
131
|
+
integrations = Registry.configured_by_method(method, @credentials)
|
|
132
|
+
if integrations.empty?
|
|
133
|
+
@prompter.say("No #{method} integrations are configured.")
|
|
134
|
+
@prompter.say("Run `messengerr credentials setup <name>` first.")
|
|
135
|
+
return nil
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
return integrations.first if integrations.length == 1
|
|
139
|
+
|
|
140
|
+
keys = integrations.map(&:integration_key)
|
|
141
|
+
chosen = @prompter.choose("Which #{method} integration?", keys)
|
|
142
|
+
integrations[keys.index(chosen)]
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def collect_message(method)
|
|
146
|
+
message = @file_message
|
|
147
|
+
if message
|
|
148
|
+
@prompter.say("Message: #{message.lines.length} line(s) from #{@flags[:html] || @flags[:message_file]}")
|
|
149
|
+
else
|
|
150
|
+
message = @prompter.ask("Message (use {{column}} placeholders):")
|
|
151
|
+
end
|
|
152
|
+
subject = ask_subject if method == :email
|
|
153
|
+
[ message, subject ]
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# Email needs a subject; re-ask now rather than failing after the wizard.
|
|
157
|
+
def ask_subject
|
|
158
|
+
loop do
|
|
159
|
+
subject = @prompter.ask("Subject ({{placeholders}} allowed):")
|
|
160
|
+
return subject unless subject.strip.empty?
|
|
161
|
+
|
|
162
|
+
@prompter.say("A subject is required for email.")
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def load_csv
|
|
167
|
+
path = @prompter.ask("Path to CSV file:")
|
|
168
|
+
source = CsvSource.from(path)
|
|
169
|
+
@prompter.say("Columns: #{source.headers.join(', ')}")
|
|
170
|
+
source
|
|
171
|
+
rescue Error => e
|
|
172
|
+
@prompter.say(e.message)
|
|
173
|
+
nil
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
# Placeholders in the subject are mapped just like body placeholders.
|
|
177
|
+
def map_placeholders(message, subject, source)
|
|
178
|
+
placeholders = Placeholder.extract(message) + Placeholder.extract(subject)
|
|
179
|
+
placeholders.uniq.to_h do |placeholder|
|
|
180
|
+
column = @prompter.choose("Map {{#{placeholder}}} to column:", source.headers)
|
|
181
|
+
[ placeholder, column ]
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def pick_contact_column(method, source)
|
|
186
|
+
label = { email: "email address", sms: "phone number" }[method]
|
|
187
|
+
@prompter.choose("Which column holds the #{label}?", source.headers)
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def validate_recipients(source, contact_column, method)
|
|
191
|
+
outcome = ContactValidator.validate(source.rows, contact_column, method)
|
|
192
|
+
|
|
193
|
+
if outcome[:defects].any?
|
|
194
|
+
@prompter.say("#{outcome[:defects].length} contact(s) have defects:")
|
|
195
|
+
outcome[:defects].each do |defect|
|
|
196
|
+
@prompter.say(" row #{defect.row_number}: #{defect.value.inspect} — #{defect.reason}")
|
|
197
|
+
end
|
|
198
|
+
unless @prompter.confirm("Drop these contacts and continue? (n = abort to fix)")
|
|
199
|
+
@prompter.say("Aborted. Fix the CSV and try again.")
|
|
200
|
+
return nil
|
|
201
|
+
end
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
if outcome[:valid].empty?
|
|
205
|
+
@prompter.say("No valid contacts to send to — aborting.")
|
|
206
|
+
return nil
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
outcome
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
# H1 guardrail: show what will actually go out before anything is sent.
|
|
213
|
+
def preview_and_confirm(recipients, message, subject, mapping, method)
|
|
214
|
+
deliveries = recipients.map do |recipient|
|
|
215
|
+
Delivery.new(
|
|
216
|
+
recipient:,
|
|
217
|
+
body: Renderer.render(message, recipient.row, mapping, escape: message_format == :html),
|
|
218
|
+
subject: subject && Renderer.render(subject, recipient.row, mapping),
|
|
219
|
+
format: message_format
|
|
220
|
+
)
|
|
221
|
+
rescue RenderError => e
|
|
222
|
+
@prompter.say("row #{recipient.row_number}: #{e.message}")
|
|
223
|
+
@prompter.say("Aborted — fix the CSV or mapping and try again.")
|
|
224
|
+
return nil
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
deliveries = resume_filter(deliveries) or return nil
|
|
228
|
+
|
|
229
|
+
@prompter.say("--- Preview (#{deliveries.length} message#{'s' if deliveries.length != 1}) ---")
|
|
230
|
+
deliveries.first(3).each do |d|
|
|
231
|
+
@prompter.say("To: #{d.recipient.contact}")
|
|
232
|
+
@prompter.say("Subject: #{d.subject}") if d.subject
|
|
233
|
+
say_body(d)
|
|
234
|
+
@prompter.say(" (#{sms_segments(d.body)} SMS segment(s))") if method == :sms
|
|
235
|
+
@prompter.say("...")
|
|
236
|
+
end
|
|
237
|
+
write_html_preview(deliveries.first) if message_format == :html
|
|
238
|
+
|
|
239
|
+
return deliveries if @flags[:dry_run] || @prompter.confirm("Send #{deliveries.length} message(s)?")
|
|
240
|
+
|
|
241
|
+
@prompter.say("Aborted — nothing was sent.")
|
|
242
|
+
nil
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
# Multi-line text is indented under "Body:"; HTML is never dumped to the terminal.
|
|
246
|
+
def say_body(delivery)
|
|
247
|
+
if delivery.html?
|
|
248
|
+
@prompter.say("Body: HTML, #{delivery.body.length} characters")
|
|
249
|
+
elsif delivery.body.include?("\n")
|
|
250
|
+
@prompter.say("Body:")
|
|
251
|
+
delivery.body.each_line { |line| @prompter.say(" #{line.chomp}") }
|
|
252
|
+
else
|
|
253
|
+
@prompter.say("Body: #{delivery.body}")
|
|
254
|
+
end
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
# Lets the user open the first rendered message in a browser before confirming.
|
|
258
|
+
def write_html_preview(delivery)
|
|
259
|
+
path = File.join(Dir.pwd, "messengerr-preview.html")
|
|
260
|
+
File.write(path, delivery.body, mode: "w", perm: 0o600)
|
|
261
|
+
@prompter.say("HTML preview of the first message: #{path}")
|
|
262
|
+
rescue SystemCallError => e
|
|
263
|
+
@prompter.say("(could not write the HTML preview: #{e.message})")
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
def resume_filter(deliveries)
|
|
267
|
+
return deliveries unless @flags[:resume]
|
|
268
|
+
|
|
269
|
+
warn_uncertain(deliveries)
|
|
270
|
+
sent = Ledger.sent_recipients(@flags[:resume]).to_set
|
|
271
|
+
skipped = deliveries.count { |d| sent.include?(recipient_key(d)) }
|
|
272
|
+
remaining = deliveries.reject { |d| sent.include?(recipient_key(d)) }
|
|
273
|
+
@prompter.say("Resuming: #{skipped} already sent, #{remaining.length} remaining.")
|
|
274
|
+
if remaining.empty?
|
|
275
|
+
@prompter.say("Nothing left to send.")
|
|
276
|
+
return nil
|
|
277
|
+
end
|
|
278
|
+
remaining
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
# How the ledger identifies a recipient: row + contact, so repeated
|
|
282
|
+
# contacts are tracked per row.
|
|
283
|
+
def recipient_key(delivery)
|
|
284
|
+
[ delivery.recipient.row_number, delivery.recipient.contact ]
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
# Earlier attempts that timed out etc. may have been delivered; --resume
|
|
288
|
+
# will send to them again, so say so before the confirmation.
|
|
289
|
+
def warn_uncertain(deliveries)
|
|
290
|
+
uncertain = Ledger.uncertain_recipients(@flags[:resume]).to_set
|
|
291
|
+
at_risk = deliveries.map { |d| recipient_key(d) }.select { |key| uncertain.include?(key) }.map(&:last)
|
|
292
|
+
return if at_risk.empty?
|
|
293
|
+
|
|
294
|
+
@prompter.say("Warning: #{at_risk.length} contact(s) had an earlier attempt with an unknown outcome " \
|
|
295
|
+
"and will be sent again — they may receive duplicates:")
|
|
296
|
+
at_risk.first(10).each { |contact| @prompter.say(" #{contact}") }
|
|
297
|
+
@prompter.say(" ...and #{at_risk.length - 10} more") if at_risk.length > 10
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
def send_and_report(integration_class, deliveries, defects)
|
|
301
|
+
integration = integration_class.new(@credentials.read(integration_class.integration_key))
|
|
302
|
+
options = build_options(integration_class)
|
|
303
|
+
ledger = Ledger.new(@flags[:ledger])
|
|
304
|
+
sender = Sender.new(integration, options:, ledger:)
|
|
305
|
+
|
|
306
|
+
if @flags[:dry_run]
|
|
307
|
+
@prompter.say("Dry run — nothing sent. Ledger skipped.")
|
|
308
|
+
return 0
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
ledger.verify_writable!
|
|
312
|
+
results = deliver_all(sender, deliveries) or return 130
|
|
313
|
+
|
|
314
|
+
loop do
|
|
315
|
+
failures = results.reject(&:sent?)
|
|
316
|
+
break if failures.empty?
|
|
317
|
+
|
|
318
|
+
list_failures(failures)
|
|
319
|
+
break if sender.halted_reason
|
|
320
|
+
break unless retry_confirmed?(failures)
|
|
321
|
+
|
|
322
|
+
retrying = deliveries.select { |d| failures.any? { |f| f.recipient == d.recipient } }
|
|
323
|
+
retried = deliver_all(sender, retrying) or return 130
|
|
324
|
+
results = results.select(&:sent?) + retried
|
|
325
|
+
end
|
|
326
|
+
|
|
327
|
+
@prompter.say("Stopped early: #{sender.halted_reason}") if sender.halted_reason
|
|
328
|
+
@prompter.say("Ledger written to #{@flags[:ledger]}")
|
|
329
|
+
report = Report.new(results:, dropped: defects)
|
|
330
|
+
@prompter.say("Done — #{report.summary}")
|
|
331
|
+
report.ok? ? 0 : 1
|
|
332
|
+
end
|
|
333
|
+
|
|
334
|
+
# → [Result], or nil if the user hit Ctrl-C (the ledger already holds
|
|
335
|
+
# everything that was delivered; unreported sends are marked uncertain).
|
|
336
|
+
def deliver_all(sender, deliveries)
|
|
337
|
+
sender.deliver(deliveries)
|
|
338
|
+
rescue Interrupt
|
|
339
|
+
@prompter.say("\nInterrupted. Ledger: #{@flags[:ledger]}")
|
|
340
|
+
@prompter.say("Resume later with: messengerr send --resume #{@flags[:ledger]}")
|
|
341
|
+
nil
|
|
342
|
+
end
|
|
343
|
+
|
|
344
|
+
def list_failures(failures)
|
|
345
|
+
@prompter.say("#{failures.length} send(s) failed:")
|
|
346
|
+
failures.each do |result|
|
|
347
|
+
line = " row #{result.recipient.row_number} (#{result.recipient.contact}): #{result.reason}"
|
|
348
|
+
line += " [uncertain — may already be delivered]" if result.uncertain
|
|
349
|
+
@prompter.say(line)
|
|
350
|
+
end
|
|
351
|
+
end
|
|
352
|
+
|
|
353
|
+
def retry_confirmed?(failures)
|
|
354
|
+
if failures.any?(&:uncertain)
|
|
355
|
+
@prompter.say("Warning: some failures are uncertain — retrying may send duplicates.")
|
|
356
|
+
end
|
|
357
|
+
@prompter.confirm("Retry failed sends?")
|
|
358
|
+
end
|
|
359
|
+
|
|
360
|
+
def build_options(integration_class)
|
|
361
|
+
options = integration_class.options_class.new
|
|
362
|
+
CLI_OPTIONS.each do |key|
|
|
363
|
+
options.public_send("#{key}=", @flags[key]) if @flags[key] && options.respond_to?("#{key}=")
|
|
364
|
+
end
|
|
365
|
+
options
|
|
366
|
+
end
|
|
367
|
+
|
|
368
|
+
def sms_segments(body)
|
|
369
|
+
body.length <= SMS_SEGMENT ? 1 : (body.length / 153.0).ceil
|
|
370
|
+
end
|
|
371
|
+
end
|
|
372
|
+
end
|
|
373
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
require "optparse"
|
|
2
|
+
|
|
3
|
+
module Messengerr
|
|
4
|
+
class CLI
|
|
5
|
+
BANNER = <<~TEXT
|
|
6
|
+
Usage: messengerr <command>
|
|
7
|
+
|
|
8
|
+
Commands:
|
|
9
|
+
credentials Manage integration credentials (setup|list|verify|delete)
|
|
10
|
+
send Send a message to a CSV contact list (see `send --help`)
|
|
11
|
+
integrations List registered integrations
|
|
12
|
+
TEXT
|
|
13
|
+
|
|
14
|
+
def self.start(argv, prompter: Prompter.new, credentials: Credentials.new, out: $stdout)
|
|
15
|
+
new(prompter:, credentials:, out:).start(argv)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def initialize(prompter:, credentials:, out: $stdout)
|
|
19
|
+
@prompter = prompter
|
|
20
|
+
@credentials = credentials
|
|
21
|
+
@out = out
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def start(argv)
|
|
25
|
+
command = argv.first
|
|
26
|
+
|
|
27
|
+
case command
|
|
28
|
+
when "credentials"
|
|
29
|
+
CLI::CredentialsCommand.new(prompter: @prompter, credentials: @credentials).run(argv[1..])
|
|
30
|
+
when "send"
|
|
31
|
+
CLI::SendCommand.new(prompter: @prompter, credentials: @credentials).run(argv[1..])
|
|
32
|
+
when "integrations"
|
|
33
|
+
CLI::IntegrationsCommand.new(prompter: @prompter, credentials: @credentials).run
|
|
34
|
+
when "-h", "--help", nil
|
|
35
|
+
@out.puts BANNER
|
|
36
|
+
0
|
|
37
|
+
when "-v", "--version"
|
|
38
|
+
@out.puts Messengerr::VERSION
|
|
39
|
+
0
|
|
40
|
+
else
|
|
41
|
+
@out.puts "messengerr: unknown command '#{command}'"
|
|
42
|
+
@out.puts BANNER
|
|
43
|
+
1
|
|
44
|
+
end
|
|
45
|
+
rescue Error => e
|
|
46
|
+
@out.puts "messengerr: #{e.message}"
|
|
47
|
+
1
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
require "uri"
|
|
2
|
+
require "phonelib"
|
|
3
|
+
|
|
4
|
+
module Messengerr
|
|
5
|
+
# Validates contact values for a delivery method. Returns valid Recipients
|
|
6
|
+
# and a list of defects the user can inspect, then drop or abort on.
|
|
7
|
+
# Repeated contacts are deliberately kept: one row = one message.
|
|
8
|
+
module ContactValidator
|
|
9
|
+
Defect = Data.define(:row_number, :value, :reason)
|
|
10
|
+
|
|
11
|
+
def self.validate(rows, contact_column, delivery_method)
|
|
12
|
+
Phonelib.default_country ||= ENV["MESSENGERR_DEFAULT_COUNTRY"] if delivery_method == :sms
|
|
13
|
+
|
|
14
|
+
valid = []
|
|
15
|
+
defects = []
|
|
16
|
+
|
|
17
|
+
rows.each_with_index do |row, index|
|
|
18
|
+
row_number = index + 2 # header is line 1
|
|
19
|
+
case check(row[contact_column], delivery_method)
|
|
20
|
+
in [ :ok, normalized ]
|
|
21
|
+
valid << Recipient.new(contact: normalized, row:, row_number:)
|
|
22
|
+
in [ :defect, reason ]
|
|
23
|
+
defects << Defect.new(row_number:, value: row[contact_column], reason:)
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
{ valid:, defects: }
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def self.check(value, delivery_method)
|
|
30
|
+
return [ :defect, "empty value" ] if value.nil? || value.strip.empty?
|
|
31
|
+
|
|
32
|
+
value = value.strip
|
|
33
|
+
case delivery_method
|
|
34
|
+
when :email
|
|
35
|
+
if value.match?(URI::MailTo::EMAIL_REGEXP) && value.split("@", 2).last.include?(".")
|
|
36
|
+
[ :ok, value.downcase ]
|
|
37
|
+
else
|
|
38
|
+
[ :defect, "invalid email address" ]
|
|
39
|
+
end
|
|
40
|
+
when :sms
|
|
41
|
+
phone = Phonelib.parse(value)
|
|
42
|
+
phone.valid? ? [ :ok, phone.e164 ] : [ :defect, "invalid phone number" ]
|
|
43
|
+
else
|
|
44
|
+
raise Error, "unknown delivery method: #{delivery_method}"
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|