belt-messaging 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.
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Belt
4
+ module Messaging
5
+ module Controllers
6
+ # Default SMS verification controller.
7
+ # Provides send-code and verify-code actions.
8
+ #
9
+ # To customize, generate the controller override:
10
+ # belt g messaging
11
+ #
12
+ # Then modify lambda/controllers/<app>/sms_verification_controller.rb
13
+ #
14
+ # This default implementation requires you to handle persistence yourself
15
+ # via the on_code_generated and on_code_verified callbacks.
16
+ class SmsVerificationController < BeltController::Base
17
+ # POST /sms-verification — send a verification code
18
+ def create
19
+ phone = params[:phone]
20
+ return error_response('Phone number is required', 422) if phone.to_s.strip.empty?
21
+
22
+ formatted = PhoneFormatter.format_e164(phone)
23
+ return error_response('Invalid phone number format', 422) unless formatted
24
+
25
+ code = SecureRandom.random_number(10**6).to_s.rjust(6, '0')
26
+
27
+ sent = Belt::Messaging.send_sms(
28
+ to: formatted,
29
+ message: "Your verification code is: #{code}"
30
+ )
31
+
32
+ unless sent
33
+ return error_response('Unable to send SMS. Please try again later.', 503)
34
+ end
35
+
36
+ Belt::Messaging.log(:info, 'SMS verification code sent', phone: formatted)
37
+ success_response(message: 'Verification code sent')
38
+ end
39
+
40
+ # PUT /sms-verification — verify the code
41
+ def update
42
+ code = params[:code].to_s.strip
43
+ return error_response('Verification code is required', 422) if code.empty?
44
+
45
+ # Default: no persistence layer — override this controller
46
+ Belt::Messaging.log(:warn,
47
+ 'SMS verification attempted but no persistence configured. Override SmsVerificationController.')
48
+
49
+ success_response(message: 'Verification not implemented — override the controller')
50
+ end
51
+ end
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,107 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'aws-sdk-sesv2'
4
+
5
+ module Belt
6
+ module Messaging
7
+ # Core email sending via AWS SESv2.
8
+ module Email
9
+ extend Logging
10
+
11
+ # Send a transactional email.
12
+ #
13
+ # All recipients must pass the EmailGate check. If any recipient is blocked,
14
+ # the entire send is rejected (returns false). This mirrors SMS behavior where
15
+ # a blocked recipient causes the send to fail. Callers who want partial delivery
16
+ # should filter recipients through EmailGate.allowed? before calling send.
17
+ #
18
+ # @param to [String, Array<String>] Recipient email address(es)
19
+ # @param subject [String] Email subject
20
+ # @param body [String] Email body (plain text)
21
+ # @param html [String, nil] Optional HTML body
22
+ # @param from [String, nil] Override the configured from address
23
+ # @return [Boolean] true if sent successfully, false otherwise
24
+ def self.send(to:, subject:, body:, html: nil, from: nil)
25
+ config = Belt::Messaging.configuration
26
+
27
+ unless config.email_enabled?
28
+ log(:warn, 'Email sending is disabled (ENABLE_EMAIL != true)')
29
+ return false
30
+ end
31
+
32
+ recipients = Array(to)
33
+ if recipients.empty?
34
+ log(:error, 'No recipients provided')
35
+ return false
36
+ end
37
+
38
+ invalid = recipients.reject { |email| valid_email?(email) }
39
+ if invalid.any?
40
+ log(:error, 'Invalid email address format', invalid: invalid)
41
+ return false
42
+ end
43
+
44
+ blocked = recipients.reject { |email| EmailGate.allowed?(email) }
45
+ if blocked.any?
46
+ log(:warn, 'Email recipients blocked by current EMAIL_MODE',
47
+ blocked: blocked, mode: config.email_mode)
48
+ return false
49
+ end
50
+
51
+ from_address = from || config.email_from_address
52
+ if from_address.nil? || from_address.empty?
53
+ log(:error, 'EMAIL_FROM_ADDRESS not configured')
54
+ return false
55
+ end
56
+
57
+ begin
58
+ client = Aws::SESV2::Client.new(region: config.aws_region)
59
+
60
+ email_content = build_email_content(subject, body, html)
61
+
62
+ client.send_email({
63
+ from_email_address: from_address,
64
+ destination: { to_addresses: recipients },
65
+ content: { simple: email_content }
66
+ })
67
+
68
+ log(:info, 'Email sent successfully', to: recipients, subject: subject)
69
+ true
70
+ rescue Aws::SESV2::Errors::AccessDeniedException => e
71
+ raise Belt::Messaging::Error,
72
+ "Email AccessDenied: ses:SendEmail not allowed. " \
73
+ "Check SES permissions in your messaging terraform module. " \
74
+ "Original: #{e.message}"
75
+ rescue Aws::SESV2::Errors::MessageRejected => e
76
+ log(:error, 'Email rejected by SES', error: e.message, to: recipients)
77
+ false
78
+ rescue Aws::SESV2::Errors::ServiceError => e
79
+ log(:error, 'Failed to send email', error: e.message, to: recipients)
80
+ false
81
+ end
82
+ end
83
+
84
+ # Validate basic email format.
85
+ # @param email [String]
86
+ # @return [Boolean]
87
+ def self.valid_email?(email)
88
+ return false if email.nil? || email.empty?
89
+
90
+ # Basic validation: has @ with something before and after
91
+ email.match?(/\A[^@\s]+@[^@\s]+\.[^@\s]+\z/)
92
+ end
93
+
94
+ def self.build_email_content(subject, body, html)
95
+ content = {
96
+ subject: { data: subject, charset: 'UTF-8' },
97
+ body: {
98
+ text: { data: body, charset: 'UTF-8' }
99
+ }
100
+ }
101
+ content[:body][:html] = { data: html, charset: 'UTF-8' } if html
102
+ content
103
+ end
104
+ private_class_method :build_email_content
105
+ end
106
+ end
107
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Belt
4
+ module Messaging
5
+ # Controls whether emails are actually sent based on EMAIL_MODE.
6
+ # Modes:
7
+ # - "none" — never send (default, safe for development)
8
+ # - "whitelist" — only send to addresses in EMAIL_WHITELIST
9
+ # - "all" — send to everyone (production)
10
+ module EmailGate
11
+ extend Logging
12
+
13
+ # Check if a given email address is allowed to receive email.
14
+ # @param email [String] Email address
15
+ # @return [Boolean]
16
+ def self.allowed?(email)
17
+ config = Belt::Messaging.configuration
18
+ mode = config.email_mode
19
+
20
+ case mode
21
+ when 'none'
22
+ false
23
+ when 'all'
24
+ true
25
+ when 'whitelist'
26
+ normalized = email.to_s.downcase.strip
27
+ config.email_whitelist.any? { |allowed| allowed.downcase.strip == normalized }
28
+ else
29
+ log(:warn, 'Unknown EMAIL_MODE, defaulting to none', mode: mode)
30
+ false
31
+ end
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Belt
4
+ module Messaging
5
+ # Shared logging helper for messaging modules.
6
+ # Routes to the configured logger, or falls back to Belt::Observability::Logger.
7
+ module Logging
8
+ private
9
+
10
+ def log(level, message, **kwargs)
11
+ logger = Belt::Messaging.configuration.logger
12
+ if logger
13
+ logger.public_send(level, message, **kwargs)
14
+ elsif defined?(Belt::Observability::Logger)
15
+ Belt::Observability::Logger.public_send(level, message, **kwargs)
16
+ end
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Belt
4
+ module Messaging
5
+ # Formats phone numbers to E.164 format (+1XXXXXXXXXX for US numbers).
6
+ module PhoneFormatter
7
+ # Format a phone number to E.164.
8
+ # @param phone [String] Phone number in any common US format
9
+ # @return [String, nil] E.164 formatted number, or nil if invalid
10
+ def self.format_e164(phone)
11
+ return nil if phone.nil? || phone.to_s.strip.empty?
12
+
13
+ # Already has + prefix — validate it
14
+ if phone.start_with?('+')
15
+ digits = phone.gsub(/\D/, '')
16
+ return "+#{digits}" if digits.length >= 11
17
+ return nil
18
+ end
19
+
20
+ # Remove all non-digit characters
21
+ digits = phone.gsub(/\D/, '')
22
+
23
+ case digits.length
24
+ when 10
25
+ # US number without country code
26
+ "+1#{digits}"
27
+ when 11
28
+ # US number with country code
29
+ digits.start_with?('1') ? "+#{digits}" : nil
30
+ else
31
+ nil
32
+ end
33
+ end
34
+
35
+ # Check if a string looks like a valid phone number.
36
+ # @param phone [String] Phone number to validate
37
+ # @return [Boolean]
38
+ def self.valid?(phone)
39
+ !format_e164(phone).nil?
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'aws-sdk-pinpointsmsvoicev2'
4
+
5
+ module Belt
6
+ module Messaging
7
+ # Core SMS sending via AWS PinpointSMSVoiceV2.
8
+ module SMS
9
+ extend Logging
10
+
11
+ # Send an SMS message.
12
+ # @param to [String] Recipient phone number (any US format)
13
+ # @param message [String] Message body (max 160 chars for single segment)
14
+ # @return [Boolean] true if sent successfully, false otherwise
15
+ def self.send(to:, message:)
16
+ config = Belt::Messaging.configuration
17
+
18
+ unless config.enabled?
19
+ log(:warn, 'SMS sending is disabled (ENABLE_SMS != true)')
20
+ return false
21
+ end
22
+
23
+ formatted_phone = PhoneFormatter.format_e164(to)
24
+ unless formatted_phone
25
+ log(:error, 'Invalid phone number format', phone: to)
26
+ return false
27
+ end
28
+
29
+ unless SmsGate.allowed?(formatted_phone)
30
+ log(:warn, 'SMS recipient not allowed by current SMS_MODE', to: formatted_phone, mode: config.sms_mode)
31
+ return false
32
+ end
33
+
34
+ origination = config.origination_number
35
+ if origination.nil? || origination.empty?
36
+ log(:error, 'SMS_ORIGINATION_NUMBER not configured')
37
+ return false
38
+ end
39
+
40
+ begin
41
+ client = Aws::PinpointSMSVoiceV2::Client.new(region: config.aws_region)
42
+
43
+ client.send_text_message({
44
+ destination_phone_number: formatted_phone,
45
+ origination_identity: origination,
46
+ message_body: message,
47
+ message_type: 'TRANSACTIONAL'
48
+ })
49
+
50
+ log(:info, 'SMS sent successfully', to: formatted_phone)
51
+ true
52
+ rescue Aws::PinpointSMSVoiceV2::Errors::AccessDeniedException => e
53
+ raise Belt::Messaging::Error,
54
+ "SMS AccessDenied: sms-voice:SendTextMessage not allowed. " \
55
+ "Check Pinpoint SMS permissions in your messaging terraform module. " \
56
+ "Original: #{e.message}"
57
+ rescue Aws::PinpointSMSVoiceV2::Errors::ServiceError => e
58
+ log(:error, 'Failed to send SMS', error: e.message, to: formatted_phone)
59
+ false
60
+ end
61
+ end
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Belt
4
+ module Messaging
5
+ # Controls whether SMS messages are actually sent based on SMS_MODE.
6
+ # Modes:
7
+ # - "none" — never send (default, safe for development)
8
+ # - "whitelist" — only send to numbers in SMS_WHITELIST
9
+ # - "all" — send to everyone (production)
10
+ module SmsGate
11
+ extend Logging
12
+
13
+ # Check if a given phone number is allowed to receive SMS.
14
+ # @param phone [String] E.164 formatted phone number
15
+ # @return [Boolean]
16
+ def self.allowed?(phone)
17
+ config = Belt::Messaging.configuration
18
+ mode = config.sms_mode
19
+
20
+ case mode
21
+ when 'none'
22
+ false
23
+ when 'all'
24
+ true
25
+ when 'whitelist'
26
+ config.sms_whitelist.include?(phone)
27
+ else
28
+ log(:warn, 'Unknown SMS_MODE, defaulting to none', mode: mode)
29
+ false
30
+ end
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,41 @@
1
+ # Lambda configuration for the messaging_events function.
2
+ # Handles inbound SMS events (STOP/START keywords) via SNS two-way messaging.
3
+ #
4
+ # Generated by: belt generate messaging
5
+ #
6
+ # Keys:
7
+ # timeout Lambda timeout in seconds
8
+ # memory_size Lambda memory in MB
9
+ # env_vars Environment variables (static values or ref() for Terraform values)
10
+ # sns_triggers SNS topic triggers (inbound SMS events)
11
+ #
12
+ # ref(name) references are resolved from lambda_env_refs in Terraform.
13
+ #
14
+ # Usage:
15
+ # belt lambda-config -e prod # View merged config for prod
16
+ # belt lambda-config -f terraform # Output as Terraform-ready lambda_config
17
+
18
+ default: &default
19
+ timeout: 30
20
+ memory_size: 256
21
+ env_vars:
22
+ ENABLE_SMS: "true"
23
+ SMS_MODE: ref(sms_mode)
24
+ SMS_ORIGINATION_NUMBER: ref(sms_origination_number)
25
+ sns_triggers:
26
+ - ref(sms_inbound_topic_arn)
27
+
28
+ dev:
29
+ <<: *default
30
+ env_vars:
31
+ ENABLE_SMS: "true"
32
+ SMS_MODE: "whitelist"
33
+ SMS_WHITELIST: ref(sms_whitelist)
34
+ SMS_ORIGINATION_NUMBER: ref(sms_origination_number)
35
+
36
+ prod:
37
+ <<: *default
38
+ env_vars:
39
+ ENABLE_SMS: "true"
40
+ SMS_MODE: "all"
41
+ SMS_ORIGINATION_NUMBER: ref(sms_origination_number)
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Generated by: belt generate messaging
4
+ # Override this controller to customize inbound SMS opt-in/opt-out handling.
5
+ # The default implementation is in Belt::Messaging::Controllers::MessagingEventsController.
6
+
7
+ require 'belt'
8
+ require 'belt/messaging'
9
+
10
+ class MessagingEventsController < BeltController::Base
11
+ OPT_OUT_KEYWORDS = Belt::Messaging::Controllers::MessagingEventsController::OPT_OUT_KEYWORDS
12
+ OPT_IN_KEYWORDS = Belt::Messaging::Controllers::MessagingEventsController::OPT_IN_KEYWORDS
13
+
14
+ def opt_out
15
+ phone = sms_origin_phone
16
+ Belt::Observability::Logger.info('Processing SMS opt-out', phone: phone)
17
+
18
+ # Implement your opt-out logic here.
19
+ # Example: User.where(phone: phone).each { |u| u.update(sms_opted_out: true) }
20
+
21
+ success_response(success: true, processed: true)
22
+ end
23
+
24
+ def opt_in
25
+ phone = sms_origin_phone
26
+ Belt::Observability::Logger.info('Processing SMS opt-in', phone: phone)
27
+
28
+ # Implement your opt-in logic here.
29
+ # Example: User.where(phone: phone).each { |u| u.update(sms_opted_out: false) }
30
+
31
+ success_response(success: true, processed: true)
32
+ end
33
+
34
+ private
35
+
36
+ def sms_origin_phone
37
+ records = @event['Records']
38
+ return nil unless records&.first
39
+
40
+ message = JSON.parse(records.first.dig('Sns', 'Message') || '{}')
41
+ message['originationNumber']
42
+ end
43
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Generated by: belt generate messaging
4
+ # Override this controller to customize SMS verification behavior.
5
+ # The default implementation is in Belt::Messaging::Controllers::SmsVerificationController.
6
+
7
+ require 'belt'
8
+ require 'belt/messaging'
9
+
10
+ class SmsVerificationController < BeltController::Base
11
+ # POST /sms-verification — send a 6-digit verification code via SMS
12
+ def create
13
+ phone = params[:phone]
14
+ return error_response('Phone number is required', 422) if phone.to_s.strip.empty?
15
+
16
+ code = SecureRandom.random_number(10**6).to_s.rjust(6, '0')
17
+
18
+ sent = Belt::Messaging.send_sms(
19
+ to: phone,
20
+ message: "Your verification code is: #{code}"
21
+ )
22
+
23
+ unless sent
24
+ return error_response('Unable to send SMS. Please try again later.', 503)
25
+ end
26
+
27
+ # Store the code — implement your own persistence here.
28
+ # Example: current_user.update(sms_verification_code: code, sms_verification_expires_at: 10.minutes.from_now)
29
+
30
+ success_response(message: 'Verification code sent')
31
+ end
32
+
33
+ # PUT /sms-verification — verify the code
34
+ def update
35
+ code = params[:code].to_s.strip
36
+ return error_response('Verification code is required', 422) if code.empty?
37
+
38
+ # Implement your own verification logic here.
39
+ # Example:
40
+ # stored_code = current_user.sms_verification_code
41
+ # return error_response('Invalid code', 422) unless code == stored_code
42
+ # current_user.update(phone_verified: true, sms_verification_code: nil)
43
+
44
+ success_response(message: 'Phone number verified', phone_verified: true)
45
+ end
46
+ end
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Generated by: belt generate messaging
4
+ # Lambda entry point for inbound SMS events (two-way messaging).
5
+ # Receives SNS notifications when customers text STOP/START to your number.
6
+
7
+ require_relative 'config/environment'
8
+
9
+ include Belt::LambdaHandler
10
+
11
+ ROUTER = Belt::ActionRouter.new(
12
+ routes: [
13
+ { verb: 'POST', path: '/opt_out', controller: 'messaging_events', action: 'opt_out' },
14
+ { verb: 'POST', path: '/opt_in', controller: 'messaging_events', action: 'opt_in' }
15
+ ],
16
+ namespace: 'messaging_events'
17
+ )
18
+
19
+ def execute(path:, body:, event:)
20
+ ROUTER.route(event: event, body: body)
21
+ end
22
+
23
+ # Override lambda_handler to detect SNS events from two-way messaging.
24
+ # Pinpoint forwards inbound SMS via SNS — the event has a Records array
25
+ # instead of httpMethod/path. We translate it into a synthetic API Gateway event.
26
+ def lambda_handler(event:, context:)
27
+ if event['Records'] && event.dig('Records', 0, 'EventSource') == 'aws:sns'
28
+ handle_sns_event(event: event, context: context)
29
+ else
30
+ super(event: event, context: context)
31
+ end
32
+ end
33
+
34
+ private
35
+
36
+ def handle_sns_event(event:, context:)
37
+ init_observability(context: context)
38
+
39
+ event['Records'].each do |record|
40
+ message = JSON.parse(record['Sns']['Message'])
41
+ keyword = message['messageBody']&.strip&.upcase
42
+
43
+ opt_out_keywords = Belt::Messaging::Controllers::MessagingEventsController::OPT_OUT_KEYWORDS
44
+ opt_in_keywords = Belt::Messaging::Controllers::MessagingEventsController::OPT_IN_KEYWORDS
45
+
46
+ action_path = if opt_out_keywords.include?(keyword)
47
+ '/opt_out'
48
+ elsif opt_in_keywords.include?(keyword)
49
+ '/opt_in'
50
+ else
51
+ Belt::Observability::Logger.info('Ignoring unrecognized SMS keyword',
52
+ keyword: keyword, phone: message['originationNumber'])
53
+ next
54
+ end
55
+
56
+ Belt::Observability::Logger.info('Routing inbound SMS',
57
+ keyword: keyword, path: action_path, phone: message['originationNumber'])
58
+
59
+ synthetic_event = {
60
+ 'httpMethod' => 'POST',
61
+ 'path' => action_path,
62
+ 'headers' => {},
63
+ 'queryStringParameters' => {},
64
+ 'pathParameters' => {},
65
+ 'Records' => event['Records'],
66
+ 'body' => nil
67
+ }
68
+
69
+ execute(path: action_path, body: {}, event: synthetic_event)
70
+ end
71
+
72
+ { statusCode: 200, body: JSON.generate(success: true) }
73
+ rescue => e
74
+ Belt::Observability::Logger.error('Unhandled error processing inbound SMS', error: e.message)
75
+ { statusCode: 500, body: JSON.generate(error: 'Internal server error') }
76
+ end