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.
Files changed (39) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/README.md +77 -0
  4. data/exe/messengerr +5 -0
  5. data/lib/messengerr/cli/credentials_command.rb +73 -0
  6. data/lib/messengerr/cli/integrations_command.rb +27 -0
  7. data/lib/messengerr/cli/send_command.rb +373 -0
  8. data/lib/messengerr/cli.rb +50 -0
  9. data/lib/messengerr/contact_validator.rb +48 -0
  10. data/lib/messengerr/credentials.rb +117 -0
  11. data/lib/messengerr/crypto.rb +39 -0
  12. data/lib/messengerr/csv_source.rb +31 -0
  13. data/lib/messengerr/delivery.rb +15 -0
  14. data/lib/messengerr/errors.rb +10 -0
  15. data/lib/messengerr/integrations/base.rb +59 -0
  16. data/lib/messengerr/integrations/clicksend.rb +92 -0
  17. data/lib/messengerr/integrations/postmark.rb +77 -0
  18. data/lib/messengerr/integrations.rb +5 -0
  19. data/lib/messengerr/ledger.rb +78 -0
  20. data/lib/messengerr/paths.rb +19 -0
  21. data/lib/messengerr/placeholder.rb +10 -0
  22. data/lib/messengerr/prompter.rb +59 -0
  23. data/lib/messengerr/recipient.rb +4 -0
  24. data/lib/messengerr/registry.rb +49 -0
  25. data/lib/messengerr/renderer.rb +30 -0
  26. data/lib/messengerr/report.rb +24 -0
  27. data/lib/messengerr/result.rb +12 -0
  28. data/lib/messengerr/sender.rb +104 -0
  29. data/lib/messengerr/sending_options/base.rb +45 -0
  30. data/lib/messengerr/sending_options/clicksend.rb +18 -0
  31. data/lib/messengerr/sending_options/postmark.rb +12 -0
  32. data/lib/messengerr/sending_options.rb +5 -0
  33. data/lib/messengerr/setups/base.rb +80 -0
  34. data/lib/messengerr/setups/clicksend.rb +13 -0
  35. data/lib/messengerr/setups/postmark.rb +9 -0
  36. data/lib/messengerr/setups.rb +5 -0
  37. data/lib/messengerr/version.rb +3 -0
  38. data/lib/messengerr.rb +22 -0
  39. 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,5 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require "messengerr"
4
+
5
+ exit Messengerr::CLI.start(ARGV)
@@ -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