Wire SMS sending via BulkVS

Replaces the SMS placeholder with an actual provider integration. v1
ships BulkVS only; the architecture leaves room for additional
providers behind the same Sms.send_message interface.

Storage:
  - EncryptedConfig key `sms_configs` (added to CONFIG_KEYS):
    { provider, enabled, basic_auth_token, from_number,
      delivery_webhook_url }
  - AccountConfig key `submitter_invitation_sms` for the per-account
    SMS body template override.

Service layer:
  - lib/sms.rb       — Sms.enabled_for?(account), Sms.send_message
                       (account:, to:, text:), Sms.normalize_phone
  - lib/sms/providers/bulkvs.rb — POST to
    https://portal.bulkvs.com/api/v1.0/messageSend with the
    pre-encoded Basic Auth header from the BulkVS portal. Surfaces
    non-2xx responses as Sms::ProviderError with the upstream message.

Background sending:
  - app/jobs/send_submitter_invitation_sms_job.rb — mirrors
    SendSubmitterInvitationEmailJob; substitutes account-template
    variables via the existing ReplaceEmailVariables module so
    {account.name} / {submitter.link} / etc. work in the SMS body.
  - submitters_controller#maybe_resend_email_sms already enqueues
    this job when params[:send_sms] == '1', so the existing
    "Send SMS" toggle in the submitter edit form now does what it
    says on the tin.

Controllers/routes:
  - SmsSettingsController gains create + test_message; the test_message
    action lets an admin verify their config with a one-off SMS
    against any phone number.
  - SubmittersSendSmsController#create powers the per-submitter
    "Send SMS" button (mirrors SubmittersSendEmailController).
  - Routes: resources :sms with create + test_message; submitters
    nested resources :send_sms.

Views:
  - app/views/sms_settings/index.html.erb — real form replacing the
    "not bundled" placeholder. Status banner reflects live config.
    Test-send card renders only when SMS is enabled.
  - app/views/submissions/_send_sms_button.html.erb — was a permanently
    disabled stub; now button_to the new send_sms endpoint when SMS
    is configured and the submitter has a phone number. Falls back to
    a tooltip explaining what's missing otherwise.
  - app/views/submissions/_send_sms.html.erb — was a placeholder render;
    now shows a real "send SMS on save" toggle when SMS is configured.
  - app/views/personalization_settings/_signature_request_sms_form.html.erb
    + show.html.erb — per-account SMS body override with variable
    documentation.

Smoke-tested in a built image:
  - /settings/sms renders 200, all form fields present.
  - /settings/personalization renders the SMS body field.
  - With saved (bogus) creds, Sms.send_message hits BulkVS over HTTPS
    and surfaces the real 401 as Sms::ProviderError — proves the
    transport is wired, not just the boot path.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
pull/687/head
Wabo 1 month ago
parent bb70a9b407
commit 1872a0995b

@ -3,14 +3,61 @@
class SmsSettingsController < ApplicationController
before_action :load_encrypted_config
authorize_resource :encrypted_config, only: :index
authorize_resource :encrypted_config, parent: false, except: :index
authorize_resource :encrypted_config, parent: false, only: %i[create test_message]
def index; end
def create
new_value = build_sms_value
if @encrypted_config.update(value: new_value)
redirect_to settings_sms_path, notice: I18n.t('changes_have_been_saved')
else
render :index, status: :unprocessable_content
end
rescue StandardError => e
flash[:alert] = e.message
render :index, status: :unprocessable_content
end
def test_message
to = params[:phone].to_s.strip
if to.blank?
flash[:alert] = 'Enter a phone number to test against.'
return redirect_to(settings_sms_path)
end
Sms.send_message(account: current_account,
to: to,
text: "Test SMS from #{Wabosign.product_name}.")
redirect_to settings_sms_path, notice: "Test SMS dispatched to #{to}."
rescue Sms::Error => e
redirect_to settings_sms_path, alert: "Test failed: #{e.message}"
rescue StandardError => e
redirect_to settings_sms_path, alert: "Unexpected error: #{e.message}"
end
private
def load_encrypted_config
@encrypted_config =
EncryptedConfig.find_or_initialize_by(account: current_account, key: 'sms_configs')
EncryptedConfig.find_or_initialize_by(account: current_account,
key: EncryptedConfig::SMS_CONFIGS_KEY)
end
def build_sms_value
submitted = params.require(:encrypted_config).permit(value: {})[:value].to_h
existing = @encrypted_config.value || {}
# Preserve the saved Basic Auth token when the field is left blank
# (the form never echoes it back, so an unedited submit would otherwise
# wipe it out).
submitted['basic_auth_token'] = existing['basic_auth_token'] if submitted['basic_auth_token'].to_s.empty?
submitted['enabled'] = submitted['enabled'].to_s == '1' || submitted['enabled'].to_s == 'true'
submitted['provider'] = (submitted['provider'].presence || 'bulkvs').to_s
submitted.compact
end
end

@ -0,0 +1,35 @@
# frozen_string_literal: true
class SubmittersSendSmsController < ApplicationController
load_and_authorize_resource :submitter
def create
if @submitter.phone.blank?
return redirect_back(fallback_location: submission_path(@submitter.submission),
alert: I18n.t('submitter_has_no_phone_number',
default: 'Submitter has no phone number.'))
end
unless Sms.enabled_for?(@submitter.account)
return redirect_back(fallback_location: submission_path(@submitter.submission),
alert: I18n.t('sms_provider_not_configured',
default: 'SMS provider is not configured.'))
end
if SubmissionEvent.exists?(submitter: @submitter,
event_type: 'send_sms',
created_at: 10.hours.ago..Time.current)
return redirect_back(fallback_location: submission_path(@submitter.submission),
alert: I18n.t('sms_has_been_sent_already',
default: 'SMS has already been sent recently.'))
end
SendSubmitterInvitationSmsJob.perform_async('submitter_id' => @submitter.id)
@submitter.sent_at ||= Time.current
@submitter.save!
redirect_back(fallback_location: submission_path(@submitter.submission),
notice: I18n.t('sms_has_been_sent', default: 'SMS has been sent.'))
end
end

@ -0,0 +1,39 @@
# frozen_string_literal: true
class SendSubmitterInvitationSmsJob
include Sidekiq::Job
sidekiq_options retry: 5
def perform(params = {})
submitter = Submitter.find(params['submitter_id'])
return if submitter.completed_at?
return if submitter.submission.archived_at?
return if submitter.template&.archived_at?
return if submitter.phone.blank?
return unless Sms.enabled_for?(submitter.account)
text = build_body(submitter)
Sms.send_message(account: submitter.account, to: submitter.phone, text: text)
SubmissionEvent.create!(submitter: submitter, event_type: 'send_sms')
submitter.sent_at ||= Time.current
submitter.save!
end
private
def build_body(submitter)
account_template = AccountConfig.find_by(account_id: submitter.account_id,
key: AccountConfig::SUBMITTER_INVITATION_SMS_KEY)
template = account_template&.value.presence ||
I18n.t('submitter_invitation_sms_body_sign',
locale: submitter.account.locale,
default: '{account.name} has invited you to sign a document: {submitter.link}')
ReplaceEmailVariables.call(template, submitter: submitter, tracking_event_type: 'click_sms')
end
end

@ -22,6 +22,7 @@
#
class AccountConfig < ApplicationRecord
SUBMITTER_INVITATION_EMAIL_KEY = 'submitter_invitation_email'
SUBMITTER_INVITATION_SMS_KEY = 'submitter_invitation_sms'
SUBMITTER_INVITATION_REMINDER_EMAIL_KEY = 'submitter_invitation_reminder_email'
SUBMITTER_COMPLETED_EMAIL_KEY = 'submitter_completed_email'
SUBMITTER_DOCUMENTS_COPY_EMAIL_KEY = 'submitter_documents_copy_email'

@ -27,7 +27,8 @@ class EncryptedConfig < ApplicationRecord
ESIGN_CERTS_KEY = 'esign_certs',
TIMESTAMP_SERVER_URL_KEY = 'timestamp_server_url',
APP_URL_KEY = 'app_url',
GOOGLE_SSO_KEY = 'google_sso_configs'
GOOGLE_SSO_KEY = 'google_sso_configs',
SMS_CONFIGS_KEY = 'sms_configs'
].freeze
belongs_to :account

@ -0,0 +1,31 @@
<div class="collapse collapse-plus bg-base-200 overflow-visible">
<input type="checkbox">
<div class="collapse-title text-xl font-medium capitalize">
<div>
Signature request SMS
</div>
</div>
<div class="collapse-content">
<% config = AccountConfigs.find_or_initialize_for_key(current_account, AccountConfig::SUBMITTER_INVITATION_SMS_KEY) %>
<% default_body = I18n.t('submitter_invitation_sms_body_sign',
default: '{account.name} has invited you to sign a document: {submitter.link}') %>
<%= form_for config, url: settings_personalization_path, method: :post, html: { autocomplete: 'off', class: 'space-y-4' } do |f| %>
<%= f.hidden_field :key %>
<div class="form-control">
<%= f.label :value, 'Message body', class: 'label' %>
<%= f.text_area :value,
value: f.object.value.is_a?(String) ? f.object.value : default_body,
rows: 4,
class: 'base-input font-mono text-sm',
dir: 'auto' %>
<span class="label-text-alt mt-1 opacity-70">
Available variables: <code>{account.name}</code>, <code>{submitter.link}</code>, <code>{submitter.name}</code>, <code>{submitter.first_name}</code>, <code>{submission.name}</code>, <code>{sender.name}</code>.
Keep it short &mdash; carriers split messages over 160 characters into multiple billable segments.
</span>
</div>
<div class="form-control pt-2">
<%= f.button button_title(title: t('save'), disabled_with: t('saving')), class: 'base-button' %>
</div>
<% end %>
</div>
</div>

@ -8,6 +8,7 @@
<%= render 'signature_request_email_form' %>
<%= render 'documents_copy_email_form' %>
<%= render 'submitter_completed_email_form' %>
<%= render 'signature_request_sms_form' %>
</div>
<p class="text-4xl font-bold mb-4 mt-8">
<%= t('company_logo') %>

@ -1,11 +0,0 @@
<div class="alert">
<%= svg_icon('info_circle', class: 'w-6 h-6') %>
<div>
<p class="font-bold">
<%= t('send_signature_requests_via_sms') %>
</p>
<p class="text-gray-700">
SMS signing invitations require an SMS provider integration (e.g. Twilio, AWS SNS) that is not bundled with this open-source edition. Wire up your preferred provider in the submitter mailer/SMS service to enable.
</p>
</div>
</div>

@ -2,7 +2,87 @@
<%= render 'shared/settings_nav' %>
<div class="flex-grow max-w-xl mx-auto">
<h1 class="text-4xl font-bold mb-4">SMS</h1>
<%= render 'placeholder' %>
<% value = @encrypted_config.value || {} %>
<% sms_live = Sms.enabled_for?(current_account) %>
<% if sms_live %>
<div class="alert alert-success mb-4">
<%= svg_icon('discount_check_filled', class: 'w-6 h-6') %>
<div>
<p class="font-bold">SMS is enabled</p>
<p class="text-gray-700">
Provider: <code><%= value['provider'].to_s.upcase %></code>.
From: <code><%= value['from_number'] %></code>.
</p>
</div>
</div>
<% else %>
<div class="alert mb-4">
<%= svg_icon('info_circle', class: 'w-6 h-6') %>
<div>
<p class="font-bold">SMS provider is not configured</p>
<p class="text-gray-700">
WaboSign currently supports <a href="https://www.bulkvs.com/" target="_blank" rel="noopener" class="link">BulkVS</a>. Paste the Basic Auth header value from the BulkVS portal below.
</p>
</div>
</div>
<% end %>
<%= form_for @encrypted_config, url: settings_sms_path, method: :post, html: { autocomplete: 'off', class: 'space-y-4' } do |f| %>
<%= f.fields_for :value do |ff| %>
<div class="form-control">
<label class="label cursor-pointer" for="encrypted_config_value_enabled">
<span class="label-text font-medium">Enable SMS</span>
<%= ff.check_box :enabled, { class: 'toggle', checked: value['enabled'] == true }, '1', '0' %>
</label>
</div>
<div class="form-control">
<%= ff.label :provider, 'Provider', class: 'label' %>
<%= ff.select :provider, [['BulkVS', 'bulkvs']], { selected: value['provider'] || 'bulkvs' }, class: 'base-select' %>
</div>
<div class="form-control">
<%= ff.label :basic_auth_token, 'BulkVS Basic Auth Token', class: 'label' %>
<%= ff.password_field :basic_auth_token, class: 'base-input', placeholder: value['basic_auth_token'].present? ? '*************' : 'Paste from BulkVS portal' %>
<% if value['basic_auth_token'].present? %>
<span class="label-text-alt mt-1 opacity-70">Leave blank to keep the saved token.</span>
<% else %>
<span class="label-text-alt mt-1 opacity-70">In the BulkVS portal, open the API tab and copy the pre-encoded Basic Auth header value (do not include "Basic ").</span>
<% end %>
</div>
<div class="form-control">
<%= ff.label :from_number, 'From Number', class: 'label' %>
<%= ff.text_field :from_number, value: value['from_number'], class: 'base-input', placeholder: '15551234567' %>
<span class="label-text-alt mt-1 opacity-70">E.164 format (digits only, country code first; e.g. <code>15551234567</code>).</span>
</div>
<div class="form-control">
<%= ff.label :delivery_webhook_url, 'Delivery Status Webhook (optional)', class: 'label' %>
<%= ff.url_field :delivery_webhook_url, value: value['delivery_webhook_url'], class: 'base-input', placeholder: 'https://your-app.example/webhooks/sms' %>
<span class="label-text-alt mt-1 opacity-70">If set, BulkVS will POST delivery-status events here for each message.</span>
</div>
<% end %>
<div class="form-control pt-2">
<%= f.button button_title(title: t('save'), disabled_with: t('saving')), class: 'base-button' %>
</div>
<% end %>
<% if sms_live %>
<div class="card bg-base-200 mt-8">
<div class="card-body p-6 space-y-3">
<p class="text-xl font-semibold">Send a test SMS</p>
<%= form_with url: test_message_settings_sms_path, method: :post, html: { autocomplete: 'off', class: 'space-y-3' } do |f| %>
<div class="form-control">
<label for="test_phone" class="label">Phone number</label>
<input type="tel" name="phone" id="test_phone" class="base-input" placeholder="15551234567" required pattern="^\+?[0-9\s\-]+$">
<span class="label-text-alt mt-1 opacity-70">A short test message is sent to this number using your saved config.</span>
</div>
<div class="form-control">
<button type="submit" class="base-button">Send test</button>
</div>
<% end %>
</div>
</div>
<% end %>
</div>
<div class="w-0 md:w-52"></div>
</div>

@ -1,3 +1,11 @@
<div class="form-control">
<%= render 'sms_settings/placeholder' %>
</div>
<% if Sms.enabled_for?(current_account) && local_assigns[:submitter]&.phone.present? %>
<div class="form-control mt-2">
<label class="label cursor-pointer" for="send_sms_toggle">
<span class="label-text">
<%= local_assigns[:resend_sms] ? t('re_send_sms') : t('send_sms') %>
on save
</span>
<%= check_box_tag 'submitter[send_sms]', '1', local_assigns.fetch(:checked, false), id: 'send_sms_toggle', class: 'toggle' %>
</label>
</div>
<% end %>

@ -1,7 +1,24 @@
<div class="mt-2 mb-1">
<div class="tooltip w-full" data-tip="SMS provider integration not configured">
<button type="button" class="btn btn-sm btn-primary text-gray-400 w-full" disabled>
<%= submitter.sent_at? ? t('re_send_sms') : t('send_sms') %>
</button>
</div>
<% if Sms.enabled_for?(current_account) %>
<% if submitter.phone.present? %>
<%= button_to submitter_send_sms_path(submitter_id: submitter.id),
method: :post,
class: 'btn btn-sm btn-primary w-full',
data: { turbo_confirm: submitter.sent_at? ? t('are_you_sure_') : nil } do %>
<%= submitter.sent_at? ? t('re_send_sms') : t('send_sms') %>
<% end %>
<% else %>
<div class="tooltip w-full" data-tip="Submitter has no phone number">
<button type="button" class="btn btn-sm btn-primary text-gray-400 w-full" disabled>
<%= t('send_sms') %>
</button>
</div>
<% end %>
<% else %>
<div class="tooltip w-full" data-tip="SMS provider is not configured (see /settings/sms)">
<button type="button" class="btn btn-sm btn-primary text-gray-400 w-full" disabled>
<%= submitter.sent_at? ? t('re_send_sms') : t('send_sms') %>
</button>
</div>
<% end %>
</div>

@ -178,13 +178,18 @@ Rails.application.routes.draw do
resources :download, only: %i[index], controller: 'submitters_download', constraints: { submitter_id: /\d+/ }
resources :download, only: %i[index], controller: 'submit_form_completed_download'
resources :send_email, only: %i[create], controller: 'submitters_send_email'
resources :send_sms, only: %i[create], controller: 'submitters_send_sms'
end
scope '/settings', as: :settings do
unless Wabosign.multitenant?
resources :storage, only: %i[index create], controller: 'storage_settings'
resources :search_entries_reindex, only: %i[create]
resources :sms, only: %i[index], controller: 'sms_settings'
resources :sms, only: %i[index create], controller: 'sms_settings' do
collection do
post :test_message
end
end
resources :mcp, only: %i[index new create destroy], controller: 'mcp_settings'
end
if Wabosign.demo? || !Wabosign.multitenant?

@ -0,0 +1,63 @@
# frozen_string_literal: true
module Sms
class Error < StandardError; end
class NotConfiguredError < Error; end
class ProviderError < Error; end
class InvalidNumberError < Error; end
SUPPORTED_PROVIDERS = %w[bulkvs].freeze
module_function
# Returns the SMS configuration hash for an account, with the same keys the
# form posts: { provider, enabled, basic_auth_token, from_number,
# delivery_webhook_url }. Returns nil if no record exists.
def configuration_for(account)
return nil if account.nil?
record = EncryptedConfig.find_by(account_id: account.id, key: EncryptedConfig::SMS_CONFIGS_KEY)
record&.value
end
def enabled_for?(account)
config = configuration_for(account)
config.is_a?(Hash) &&
config['enabled'] &&
SUPPORTED_PROVIDERS.include?(config['provider'].to_s) &&
config['basic_auth_token'].to_s.present? &&
config['from_number'].to_s.present?
end
# Send an SMS via the account's configured provider.
#
# account: a WaboSign Account record
# to: the recipient phone number (E.164 string, leading + tolerated)
# text: the message body (already variable-substituted)
# webhook: optional override of the per-message delivery_status_webhook_url
#
# Returns the provider's parsed JSON response on success. Raises
# NotConfiguredError or ProviderError on failure.
def send_message(account:, to:, text:, webhook: nil)
config = configuration_for(account)
raise NotConfiguredError, 'SMS provider is not configured' unless enabled_for?(account)
provider = config['provider'].to_s
case provider
when 'bulkvs'
Sms::Providers::Bulkvs.new(config).deliver(to: to, text: text, webhook: webhook)
else
raise NotConfiguredError, "Unsupported SMS provider: #{provider.inspect}"
end
end
# Normalize a phone number to E.164 (digits-only, no '+'). BulkVS expects
# eleven-digit US numbers like 15551234567; international numbers are passed
# through as-is once stripped of formatting characters.
def normalize_phone(raw)
digits = raw.to_s.gsub(/[^\d]/, '')
raise InvalidNumberError, "Invalid phone number: #{raw.inspect}" if digits.length < 8
digits
end
end

@ -0,0 +1,76 @@
# frozen_string_literal: true
module Sms
module Providers
# Thin wrapper around the BulkVS messageSend API.
#
# Docs: https://portal.bulkvs.com/api/v1.0/documentation
#
# Request shape:
# POST /api/v1.0/messageSend
# Authorization: Basic <pre-encoded token from the BulkVS portal>
# Content-Type: application/json
# { "From": "<e164>", "To": ["<e164>", ...], "Message": "<text>",
# "delivery_status_webhook_url": "<optional>" }
#
# Response shape (success): JSON with at least { "Status": "...", ... }
# Response shape (error): non-2xx + JSON body with error details
class Bulkvs
ENDPOINT = 'https://portal.bulkvs.com/api/v1.0/messageSend'
TIMEOUT_SECONDS = 15
def initialize(config)
@token = config['basic_auth_token'].to_s.strip
@from = Sms.normalize_phone(config['from_number'])
@config_webhook = config['delivery_webhook_url'].to_s.strip
end
def deliver(to:, text:, webhook: nil)
body = {
'From' => @from,
'To' => Array(to).map { |n| Sms.normalize_phone(n) },
'Message' => text.to_s
}
effective_webhook = webhook.presence || @config_webhook.presence
body['delivery_status_webhook_url'] = effective_webhook if effective_webhook
response = http_post(body)
parse_response!(response, body)
end
private
def http_post(body)
uri = URI(ENDPOINT)
http = Net::HTTP.new(uri.host, uri.port)
http.use_ssl = true
http.read_timeout = TIMEOUT_SECONDS
http.open_timeout = TIMEOUT_SECONDS
request = Net::HTTP::Post.new(uri.request_uri)
request['Authorization'] = "Basic #{@token}"
request['Content-Type'] = 'application/json'
request['Accept'] = 'application/json'
request.body = JSON.generate(body)
http.request(request)
end
def parse_response!(response, request_body)
body = begin
response.body.to_s.empty? ? {} : JSON.parse(response.body)
rescue JSON::ParserError
{ 'raw' => response.body.to_s }
end
return body if response.is_a?(Net::HTTPSuccess)
message = body['Description'] || body['Status'] || body['error'] ||
body['raw'] || "HTTP #{response.code}"
raise Sms::ProviderError,
"BulkVS rejected request (HTTP #{response.code}): #{message}. " \
"Request body: #{JSON.generate(request_body.except('Message'))}."
end
end
end
end
Loading…
Cancel
Save