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
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
require "yaml"
|
|
2
|
+
require "fileutils"
|
|
3
|
+
require "securerandom"
|
|
4
|
+
|
|
5
|
+
module Messengerr
|
|
6
|
+
# Encrypted YAML store for integration credentials (Rails-credentials-style).
|
|
7
|
+
#
|
|
8
|
+
# Threat model: the encrypted file protects credentials only when the master
|
|
9
|
+
# key is kept separate from it. Set MESSENGERR_MASTER_KEY (hex, 64 chars) to
|
|
10
|
+
# keep the key out of the filesystem entirely. Otherwise a master.key file
|
|
11
|
+
# (mode 600, dir 700) is generated next to the credentials file — equivalent
|
|
12
|
+
# in practice to a mode-600 plaintext file, protecting mainly against the
|
|
13
|
+
# .enc file leaking on its own (backups, VCS, shared systems).
|
|
14
|
+
class Credentials
|
|
15
|
+
def initialize(credentials_path: Paths.credentials_file, key_path: Paths.master_key_file)
|
|
16
|
+
@credentials_path = credentials_path
|
|
17
|
+
@key_path = key_path
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def read(integration_key)
|
|
21
|
+
data.fetch("integrations", {}).fetch(integration_key.to_s, nil)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def write(integration_key, values)
|
|
25
|
+
merged = data
|
|
26
|
+
merged["integrations"] ||= {}
|
|
27
|
+
merged["integrations"][integration_key.to_s] = values.transform_keys(&:to_s)
|
|
28
|
+
persist(merged)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def configured?(integration_key)
|
|
32
|
+
!read(integration_key).nil?
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def keys
|
|
36
|
+
data.fetch("integrations", {}).keys
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def exists?
|
|
40
|
+
File.exist?(@credentials_path)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def delete_file!
|
|
44
|
+
FileUtils.rm_f(@credentials_path)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def purge!
|
|
48
|
+
delete_file!
|
|
49
|
+
FileUtils.rm_f(@key_path)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
private
|
|
53
|
+
|
|
54
|
+
def data
|
|
55
|
+
return {} unless exists?
|
|
56
|
+
|
|
57
|
+
YAML.safe_load(Crypto.decrypt(File.read(@credentials_path), master_key(read_only: true))) || {}
|
|
58
|
+
rescue Psych::SyntaxError
|
|
59
|
+
raise CredentialsError, "credentials file decrypted to invalid YAML — it may be corrupted"
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def persist(hash)
|
|
63
|
+
dir = File.dirname(@credentials_path)
|
|
64
|
+
# mkdir_p creates missing directories 0700; directories that already
|
|
65
|
+
# exist (e.g. a shared MESSENGERR_CONFIG_DIR) are never chmod-ed.
|
|
66
|
+
FileUtils.mkdir_p(dir, mode: 0o700)
|
|
67
|
+
write_secret(@credentials_path, Crypto.encrypt(YAML.dump(hash), master_key))
|
|
68
|
+
rescue Errno::EACCES, Errno::EROFS
|
|
69
|
+
raise CredentialsError,
|
|
70
|
+
"cannot write #{@credentials_path} — check permissions on #{dir}"
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def master_key(read_only: false)
|
|
74
|
+
env_key = ENV["MESSENGERR_MASTER_KEY"]
|
|
75
|
+
return env_master_key(env_key) if env_key
|
|
76
|
+
|
|
77
|
+
if File.exist?(@key_path)
|
|
78
|
+
hex = File.read(@key_path).strip
|
|
79
|
+
unless hex.match?(/\A[0-9a-fA-F]{64}\z/)
|
|
80
|
+
raise CredentialsError, "#{@key_path} is malformed — run `messengerr credentials delete --purge` to reset"
|
|
81
|
+
end
|
|
82
|
+
[ hex ].pack("H*")
|
|
83
|
+
elsif read_only
|
|
84
|
+
raise CredentialsError,
|
|
85
|
+
"credentials exist but no master key is available — set MESSENGERR_MASTER_KEY " \
|
|
86
|
+
"or run `messengerr credentials delete` to reset"
|
|
87
|
+
else
|
|
88
|
+
key = SecureRandom.random_bytes(32)
|
|
89
|
+
write_secret(@key_path, key.unpack1("H*"))
|
|
90
|
+
key
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# An explicitly set key must be valid: an empty or malformed value is a
|
|
95
|
+
# misconfiguration, never a reason to fall back to a key file silently.
|
|
96
|
+
def env_master_key(value)
|
|
97
|
+
hex = value.strip
|
|
98
|
+
unless hex.match?(/\A\h{64}\z/)
|
|
99
|
+
raise CredentialsError,
|
|
100
|
+
"MESSENGERR_MASTER_KEY must be 64 hex characters (32 bytes)" \
|
|
101
|
+
"#{' — it is set but empty' if hex.empty?}; unset it to use the key file instead"
|
|
102
|
+
end
|
|
103
|
+
[ hex ].pack("H*")
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Create files 0600 from birth and write atomically (temp + rename) so a
|
|
107
|
+
# crash never leaves a truncated credentials file.
|
|
108
|
+
def write_secret(path, contents)
|
|
109
|
+
dir = File.dirname(path)
|
|
110
|
+
FileUtils.mkdir_p(dir, mode: 0o700)
|
|
111
|
+
|
|
112
|
+
tmp = File.join(dir, ".#{File.basename(path)}.tmp")
|
|
113
|
+
File.open(tmp, File::WRONLY | File::CREAT | File::TRUNC, 0o600) { |f| f.write(contents) }
|
|
114
|
+
File.rename(tmp, path)
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
require "openssl"
|
|
2
|
+
require "base64"
|
|
3
|
+
|
|
4
|
+
module Messengerr
|
|
5
|
+
# AES-256-GCM encryption. Encrypted payload is base64(iv + ciphertext + tag).
|
|
6
|
+
module Crypto
|
|
7
|
+
CIPHER = "aes-256-gcm"
|
|
8
|
+
IV_LENGTH = 12
|
|
9
|
+
TAG_LENGTH = 16
|
|
10
|
+
|
|
11
|
+
def self.encrypt(plaintext, key)
|
|
12
|
+
cipher = OpenSSL::Cipher.new(CIPHER).encrypt
|
|
13
|
+
cipher.key = key
|
|
14
|
+
iv = cipher.random_iv
|
|
15
|
+
ciphertext = cipher.update(plaintext) + cipher.final
|
|
16
|
+
Base64.strict_encode64(iv + ciphertext + cipher.auth_tag)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def self.decrypt(payload, key)
|
|
20
|
+
data = Base64.strict_decode64(payload)
|
|
21
|
+
if data.bytesize < IV_LENGTH + TAG_LENGTH + 1
|
|
22
|
+
raise CredentialsError, "credentials file is truncated or malformed"
|
|
23
|
+
end
|
|
24
|
+
iv, rest = data[0, IV_LENGTH], data[IV_LENGTH..]
|
|
25
|
+
ciphertext, tag = rest[0...-TAG_LENGTH], rest[-TAG_LENGTH..]
|
|
26
|
+
|
|
27
|
+
cipher = OpenSSL::Cipher.new(CIPHER).decrypt
|
|
28
|
+
cipher.key = key
|
|
29
|
+
cipher.iv = iv
|
|
30
|
+
cipher.auth_tag = tag
|
|
31
|
+
cipher.auth_data = ""
|
|
32
|
+
cipher.update(ciphertext) + cipher.final
|
|
33
|
+
rescue CredentialsError
|
|
34
|
+
raise
|
|
35
|
+
rescue StandardError
|
|
36
|
+
raise CredentialsError, "credentials could not be decrypted — master key is missing or incorrect"
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
require "csv"
|
|
2
|
+
|
|
3
|
+
module Messengerr
|
|
4
|
+
# Local CSV file → headers + string-keyed row hashes.
|
|
5
|
+
class CsvSource
|
|
6
|
+
attr_reader :headers, :rows
|
|
7
|
+
|
|
8
|
+
def self.from(path)
|
|
9
|
+
expanded = File.expand_path(path)
|
|
10
|
+
raise Error, "CSV file not found: #{path}" unless File.file?(expanded)
|
|
11
|
+
|
|
12
|
+
table = CSV.read(expanded, headers: true, encoding: "bom|utf-8")
|
|
13
|
+
headers = table.headers.compact
|
|
14
|
+
raise Error, "CSV has no header row: #{path}" if headers.empty?
|
|
15
|
+
|
|
16
|
+
duplicates = headers.tally.select { |_, count| count > 1 }.keys
|
|
17
|
+
unless duplicates.empty?
|
|
18
|
+
raise Error, "CSV has duplicate headers: #{duplicates.join(', ')}"
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
new(headers: table.headers, rows: table.map(&:to_h))
|
|
22
|
+
rescue CSV::MalformedCSVError, Encoding::InvalidByteSequenceError, Encoding::UndefinedConversionError => e
|
|
23
|
+
raise Error, "could not parse CSV #{path}: #{e.message}"
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def initialize(headers:, rows:)
|
|
27
|
+
@headers = headers
|
|
28
|
+
@rows = rows
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
module Messengerr
|
|
2
|
+
# One fully-rendered outbound message: who it goes to, the body, and an
|
|
3
|
+
# optional per-recipient subject (email). `format` says how to read `body`:
|
|
4
|
+
# :text (the default) or :html. Only integrations that declare :html in
|
|
5
|
+
# `.formats` are ever handed an :html delivery.
|
|
6
|
+
Delivery = Data.define(:recipient, :body, :subject, :format) do
|
|
7
|
+
def initialize(recipient:, body:, subject:, format: :text)
|
|
8
|
+
raise ArgumentError, "unknown delivery format: #{format.inspect}" unless %i[text html].include?(format)
|
|
9
|
+
|
|
10
|
+
super
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def html? = format == :html
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
module Messengerr
|
|
2
|
+
class Error < StandardError; end
|
|
3
|
+
class CredentialsError < Error; end
|
|
4
|
+
class IntegrationError < Error; end
|
|
5
|
+
# The provider may have accepted the request (timeout after sending, 5xx,
|
|
6
|
+
# unreadable response). Retrying can duplicate the message.
|
|
7
|
+
class UncertainError < IntegrationError; end
|
|
8
|
+
class ValidationError < Error; end
|
|
9
|
+
class RenderError < Error; end
|
|
10
|
+
end
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
module Messengerr
|
|
2
|
+
module Integrations
|
|
3
|
+
# Contract every delivery integration implements. Instances are built with
|
|
4
|
+
# the integration's decrypted credentials hash.
|
|
5
|
+
class Base
|
|
6
|
+
# :email or :sms — declared by the integration, not encoded in the namespace.
|
|
7
|
+
def self.delivery_method
|
|
8
|
+
raise NotImplementedError, "#{name} must declare .delivery_method"
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
# Body formats this integration can send: :text, and :html if it
|
|
12
|
+
# delivers HTML. The CLI refuses --html for an integration that doesn't
|
|
13
|
+
# list :html, so markup is never sent as plain text by mistake.
|
|
14
|
+
def self.formats
|
|
15
|
+
[ :text ]
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def self.integration_key
|
|
19
|
+
name.split("::").last.downcase.to_sym
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Parallel namespaces resolved by convention; override to wire explicitly.
|
|
23
|
+
def self.setup_class
|
|
24
|
+
Messengerr::Setups.const_get(class_name, false)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def self.options_class
|
|
28
|
+
Messengerr::SendingOptions.const_get(class_name, false)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def self.class_name
|
|
32
|
+
name.split("::").last
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def self.inherited(subclass)
|
|
36
|
+
super
|
|
37
|
+
Registry.register(subclass) if subclass.name
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def initialize(credentials)
|
|
41
|
+
@credentials = credentials
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Send each Delivery (recipient + rendered body + optional subject).
|
|
45
|
+
# → Array of Result, one per delivery — exactly one per recipient.
|
|
46
|
+
# Integrations that send one by one should `yield` each Result as it lands
|
|
47
|
+
# (optional block) so the ledger survives a crash mid-batch. Mark a failure
|
|
48
|
+
# `uncertain: true` unless the provider definitely rejected it.
|
|
49
|
+
def send_batch(deliveries, options)
|
|
50
|
+
raise NotImplementedError, "#{self.class.name} must implement #send_batch"
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Cheap live call proving the credentials work; raise IntegrationError on failure.
|
|
54
|
+
def verify!
|
|
55
|
+
raise NotImplementedError, "#{self.class.name} must implement #verify!"
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
require "net/http"
|
|
2
|
+
require "json"
|
|
3
|
+
require "uri"
|
|
4
|
+
|
|
5
|
+
module Messengerr
|
|
6
|
+
module Integrations
|
|
7
|
+
# ClickSend — SMS delivery via the v3 REST API using basic auth
|
|
8
|
+
# (username + api_key). No SDK dependency.
|
|
9
|
+
class ClickSend < Base
|
|
10
|
+
API_URL = "https://rest.clicksend.com/v3"
|
|
11
|
+
|
|
12
|
+
def self.delivery_method
|
|
13
|
+
:sms
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def send_batch(deliveries, options)
|
|
17
|
+
body = {
|
|
18
|
+
messages: deliveries.map { |delivery|
|
|
19
|
+
{ to: delivery.recipient.contact, body: delivery.body }.tap do |m|
|
|
20
|
+
m[:from] = options.from || @credentials["from"] if options.from || @credentials["from"]
|
|
21
|
+
m[:schedule] = options.schedule if options.schedule
|
|
22
|
+
end
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
response = post("/sms/send", body)
|
|
26
|
+
results = response.dig("data", "messages") || []
|
|
27
|
+
deliveries.each_with_index.map do |delivery, index|
|
|
28
|
+
entry = results[index]
|
|
29
|
+
if entry && %w[SUCCESS QUEUED].include?(entry["status"])
|
|
30
|
+
Result.new(recipient: delivery.recipient, status: :sent, provider_id: entry["message_id"])
|
|
31
|
+
else
|
|
32
|
+
Result.new(recipient: delivery.recipient, status: :failed,
|
|
33
|
+
reason: entry ? entry["status"] : "no status returned",
|
|
34
|
+
uncertain: entry.nil?)
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
rescue IntegrationError => e
|
|
38
|
+
uncertain = e.is_a?(UncertainError)
|
|
39
|
+
deliveries.map { |d| Result.new(recipient: d.recipient, status: :failed, reason: e.message, uncertain:) }
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def verify!
|
|
43
|
+
response = request(:get, "/account")
|
|
44
|
+
raise IntegrationError, "ClickSend verification failed (#{response.code})" unless response.is_a?(Net::HTTPSuccess)
|
|
45
|
+
|
|
46
|
+
true
|
|
47
|
+
rescue IntegrationError
|
|
48
|
+
raise
|
|
49
|
+
rescue StandardError => e
|
|
50
|
+
raise IntegrationError, "ClickSend unreachable: #{e.message}"
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
private
|
|
54
|
+
|
|
55
|
+
def post(path, payload)
|
|
56
|
+
response = request(:post, path, payload)
|
|
57
|
+
unless response.is_a?(Net::HTTPSuccess)
|
|
58
|
+
# 4xx = definitively rejected. 5xx (incl. gateway 502/504) can happen
|
|
59
|
+
# after the batch was queued, so the outcome is unknown.
|
|
60
|
+
error = response.code.to_i >= 500 ? UncertainError : IntegrationError
|
|
61
|
+
raise error, "ClickSend rejected the request (#{response.code}): #{response.body.to_s[0, 200]}"
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
JSON.parse(response.body)
|
|
65
|
+
rescue JSON::ParserError
|
|
66
|
+
raise UncertainError, "ClickSend returned an unreadable response (#{response.code}) — status unknown"
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def request(verb, path, payload = nil)
|
|
70
|
+
uri = URI("#{API_URL}#{path}")
|
|
71
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
72
|
+
http.use_ssl = true
|
|
73
|
+
http.open_timeout = 5
|
|
74
|
+
http.read_timeout = 10
|
|
75
|
+
|
|
76
|
+
request = verb == :get ? Net::HTTP::Get.new(uri) : Net::HTTP::Post.new(uri)
|
|
77
|
+
request.basic_auth(@credentials["username"], @credentials["api_key"])
|
|
78
|
+
if payload
|
|
79
|
+
request["Content-Type"] = "application/json"
|
|
80
|
+
request.body = JSON.dump(payload)
|
|
81
|
+
end
|
|
82
|
+
http.request(request)
|
|
83
|
+
rescue Net::OpenTimeout, Errno::ECONNREFUSED, SocketError, OpenSSL::SSL::SSLError => e
|
|
84
|
+
# Failed before the request reached ClickSend: definitely not sent.
|
|
85
|
+
raise IntegrationError, "ClickSend unreachable: #{e.message}"
|
|
86
|
+
rescue Net::ReadTimeout, Net::WriteTimeout, Errno::ECONNRESET, Errno::EPIPE, EOFError => e
|
|
87
|
+
# The request may have been processed before the connection died.
|
|
88
|
+
raise UncertainError, "ClickSend connection lost mid-request (status unknown): #{e.message}"
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
module Messengerr
|
|
2
|
+
module Integrations
|
|
3
|
+
# Postmark — email delivery via the postmark gem (lazy-loaded).
|
|
4
|
+
class Postmark < Base
|
|
5
|
+
def self.delivery_method
|
|
6
|
+
:email
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def self.formats
|
|
10
|
+
[ :text, :html ]
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def send_batch(deliveries, options)
|
|
14
|
+
deliveries.map do |delivery|
|
|
15
|
+
deliver_one(delivery, options).tap { |result| yield result if block_given? }
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def verify!
|
|
20
|
+
client.server_info
|
|
21
|
+
true
|
|
22
|
+
rescue LoadError
|
|
23
|
+
raise
|
|
24
|
+
rescue StandardError => e
|
|
25
|
+
raise IntegrationError, "Postmark verification failed: #{e.message}"
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
def deliver_one(delivery, options)
|
|
31
|
+
subject = delivery.subject || options.subject
|
|
32
|
+
if subject.to_s.strip.empty?
|
|
33
|
+
return Result.new(recipient: delivery.recipient, status: :failed, reason: "subject is required for email sends")
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
response = client.deliver(
|
|
37
|
+
{
|
|
38
|
+
from: options.from || @credentials["from"],
|
|
39
|
+
to: delivery.recipient.contact,
|
|
40
|
+
subject:,
|
|
41
|
+
**body_params(delivery),
|
|
42
|
+
message_stream: options.message_stream,
|
|
43
|
+
tag: options.tag,
|
|
44
|
+
track_opens: options.track_opens
|
|
45
|
+
}.compact
|
|
46
|
+
)
|
|
47
|
+
Result.new(recipient: delivery.recipient, status: :sent, provider_id: response[:message_id])
|
|
48
|
+
rescue StandardError => e
|
|
49
|
+
Result.new(recipient: delivery.recipient, status: :failed, reason: e.message,
|
|
50
|
+
uncertain: !definitely_rejected?(e))
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Exactly one body: HTML mail is HTML only, text mail is text only.
|
|
54
|
+
def body_params(delivery)
|
|
55
|
+
delivery.html? ? { html_body: delivery.body } : { text_body: delivery.body }
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Only a 4xx from the API proves the message was not accepted. Timeouts,
|
|
59
|
+
# connection errors, 5xx and anything unrecognised may have gone through.
|
|
60
|
+
def definitely_rejected?(error)
|
|
61
|
+
defined?(::Postmark::HttpServerError) &&
|
|
62
|
+
error.is_a?(::Postmark::HttpServerError) &&
|
|
63
|
+
(400..499).cover?(error.status_code)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def client
|
|
67
|
+
@client ||= begin
|
|
68
|
+
require "postmark"
|
|
69
|
+
::Postmark::ApiClient.new(@credentials["server_token"])
|
|
70
|
+
rescue LoadError
|
|
71
|
+
raise IntegrationError,
|
|
72
|
+
"the postmark integration requires the 'postmark' gem — add it to your Gemfile or `gem install postmark`"
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
require "fileutils"
|
|
3
|
+
require "time"
|
|
4
|
+
|
|
5
|
+
module Messengerr
|
|
6
|
+
# Durable record of send outcomes, one JSONL line per Result, appended as
|
|
7
|
+
# each result lands so a crash mid-run loses nothing already delivered —
|
|
8
|
+
# and `--resume` can skip contacts already marked sent. It holds every
|
|
9
|
+
# contact's address/number, so it is created owner-only (0600).
|
|
10
|
+
class Ledger
|
|
11
|
+
def initialize(path)
|
|
12
|
+
@path = path
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# Fail before anything is sent if the ledger can't be written.
|
|
16
|
+
def verify_writable!
|
|
17
|
+
FileUtils.mkdir_p(File.dirname(@path))
|
|
18
|
+
File.open(@path, "a", 0o600) { }
|
|
19
|
+
rescue SystemCallError, IOError => e
|
|
20
|
+
raise Error, "cannot write ledger #{@path}: #{e.message}"
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def record(result)
|
|
24
|
+
FileUtils.mkdir_p(File.dirname(@path))
|
|
25
|
+
File.open(@path, "a", 0o600) do |file|
|
|
26
|
+
file.puts(JSON.dump(
|
|
27
|
+
row_number: result.recipient.row_number,
|
|
28
|
+
contact: result.recipient.contact,
|
|
29
|
+
status: result.status,
|
|
30
|
+
reason: result.reason,
|
|
31
|
+
provider_id: result.provider_id,
|
|
32
|
+
uncertain: result.uncertain,
|
|
33
|
+
sent_at: Time.now.utc.iso8601
|
|
34
|
+
))
|
|
35
|
+
file.flush
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Recipients already sent per this ledger — used by --resume. A recipient
|
|
40
|
+
# is [row_number, contact]: repeated contacts are separate messages, so
|
|
41
|
+
# sending row 2 to a@x.com says nothing about row 5 to the same address.
|
|
42
|
+
def self.sent_recipients(path)
|
|
43
|
+
outcomes(path).select { |_, state| state == :sent }.keys
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Recipients whose last attempt failed *uncertainly* and never later
|
|
47
|
+
# succeeded: they may already have received the message.
|
|
48
|
+
def self.uncertain_recipients(path)
|
|
49
|
+
outcomes(path).select { |_, state| state == :uncertain }.keys
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def self.default_path
|
|
53
|
+
File.join(Dir.pwd, "messengerr-ledger-#{Time.now.utc.strftime('%Y%m%d-%H%M%S')}.jsonl")
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# [row_number, contact] → :sent | :uncertain | :failed. :sent is terminal.
|
|
57
|
+
def self.outcomes(path)
|
|
58
|
+
return {} unless File.exist?(path)
|
|
59
|
+
|
|
60
|
+
File.readlines(path).each_with_object({}) do |line, states|
|
|
61
|
+
entry = JSON.parse(line)
|
|
62
|
+
next if entry["contact"].nil?
|
|
63
|
+
|
|
64
|
+
recipient = [ entry["row_number"], entry["contact"] ]
|
|
65
|
+
next if states[recipient] == :sent
|
|
66
|
+
|
|
67
|
+
states[recipient] =
|
|
68
|
+
if entry["status"] == "sent" then :sent
|
|
69
|
+
elsif entry["uncertain"] then :uncertain
|
|
70
|
+
else :failed
|
|
71
|
+
end
|
|
72
|
+
rescue JSON::ParserError
|
|
73
|
+
next # a torn final line from a crash
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
private_class_method :outcomes
|
|
77
|
+
end
|
|
78
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
module Messengerr
|
|
2
|
+
# Locations for messengerr-owned files. Credentials live in the user's
|
|
3
|
+
# config directory (~/.config/messengerr by default, overridable with
|
|
4
|
+
# MESSENGERR_CONFIG_DIR) so they survive gem upgrades.
|
|
5
|
+
module Paths
|
|
6
|
+
def self.config_dir
|
|
7
|
+
override = ENV["MESSENGERR_CONFIG_DIR"].to_s.strip
|
|
8
|
+
override.empty? ? File.join(Dir.home, ".config", "messengerr") : override
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def self.credentials_file
|
|
12
|
+
File.join(config_dir, "credentials.yml.enc")
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def self.master_key_file
|
|
16
|
+
File.join(config_dir, "master.key")
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
require "io/console"
|
|
2
|
+
|
|
3
|
+
module Messengerr
|
|
4
|
+
# Injectable stdin/stdout interaction so CLI flows are scriptable and testable.
|
|
5
|
+
class Prompter
|
|
6
|
+
def initialize(input: $stdin, output: $stdout)
|
|
7
|
+
@input = input
|
|
8
|
+
@output = output
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def say(message)
|
|
12
|
+
@output.puts(message)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def ask(question)
|
|
16
|
+
@output.print("#{question} ")
|
|
17
|
+
@output.flush
|
|
18
|
+
read_line
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def secret(question)
|
|
22
|
+
@output.print("#{question} ")
|
|
23
|
+
@output.flush
|
|
24
|
+
if @input.respond_to?(:noecho) && @input.tty?
|
|
25
|
+
value = @input.noecho(&:gets)&.chomp
|
|
26
|
+
@output.puts
|
|
27
|
+
value
|
|
28
|
+
else
|
|
29
|
+
read_line
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def choose(question, options)
|
|
34
|
+
@output.puts(question)
|
|
35
|
+
options.each_with_index { |o, i| @output.puts(" #{i + 1}) #{o}") }
|
|
36
|
+
loop do
|
|
37
|
+
answer = ask("Choose 1-#{options.length}:")
|
|
38
|
+
index = Integer(answer, exception: false)
|
|
39
|
+
return options[index - 1] if index&.between?(1, options.length)
|
|
40
|
+
|
|
41
|
+
@output.puts("Please enter a number between 1 and #{options.length}.")
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def confirm(question)
|
|
46
|
+
answer = ask("#{question} [y/n]")
|
|
47
|
+
answer.to_s.strip.downcase.start_with?("y")
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
def read_line
|
|
53
|
+
line = @input.gets
|
|
54
|
+
raise Error, "unexpected end of input" if line.nil?
|
|
55
|
+
|
|
56
|
+
line.chomp
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
module Messengerr
|
|
2
|
+
# Lookup table of registered integration classes. Built-in integrations
|
|
3
|
+
# register themselves when Zeitwerk loads them; community gems call
|
|
4
|
+
# Messengerr.register_integration on load.
|
|
5
|
+
module Registry
|
|
6
|
+
@integrations = []
|
|
7
|
+
|
|
8
|
+
class << self
|
|
9
|
+
def register(integration_class)
|
|
10
|
+
raise Error, "anonymous classes can't be registered as integrations" if integration_class.name.nil?
|
|
11
|
+
|
|
12
|
+
existing = @integrations.find { |i| i.integration_key == integration_class.integration_key }
|
|
13
|
+
if existing && existing != integration_class
|
|
14
|
+
raise Error,
|
|
15
|
+
"integration key :#{integration_class.integration_key} is already taken by #{existing.name}"
|
|
16
|
+
end
|
|
17
|
+
@integrations << integration_class unless @integrations.include?(integration_class)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def all
|
|
21
|
+
ensure_loaded
|
|
22
|
+
@integrations.dup
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def by_method(delivery_method)
|
|
26
|
+
all.select { |i| i.delivery_method == delivery_method.to_sym }
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def find(integration_key)
|
|
30
|
+
all.find { |i| i.integration_key == integration_key.to_sym }
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def configured(credentials = Credentials.new)
|
|
34
|
+
all.select { |i| credentials.configured?(i.integration_key) }
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def configured_by_method(delivery_method, credentials = Credentials.new)
|
|
38
|
+
by_method(delivery_method).select { |i| credentials.configured?(i.integration_key) }
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
# Touch the built-in integration constants so Zeitwerk loads (and registers) them.
|
|
44
|
+
def ensure_loaded
|
|
45
|
+
Integrations.constants.each { |c| Integrations.const_get(c) }
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|