diff --git a/app/controllers/sms_settings_controller.rb b/app/controllers/sms_settings_controller.rb
index 96168605..5c6498b8 100644
--- a/app/controllers/sms_settings_controller.rb
+++ b/app/controllers/sms_settings_controller.rb
@@ -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
diff --git a/app/controllers/submitters_send_sms_controller.rb b/app/controllers/submitters_send_sms_controller.rb
new file mode 100644
index 00000000..8a6828de
--- /dev/null
+++ b/app/controllers/submitters_send_sms_controller.rb
@@ -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
diff --git a/app/jobs/send_submitter_invitation_sms_job.rb b/app/jobs/send_submitter_invitation_sms_job.rb
new file mode 100644
index 00000000..fcaba188
--- /dev/null
+++ b/app/jobs/send_submitter_invitation_sms_job.rb
@@ -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
diff --git a/app/models/account_config.rb b/app/models/account_config.rb
index 2db37d70..eb4aabd7 100644
--- a/app/models/account_config.rb
+++ b/app/models/account_config.rb
@@ -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'
diff --git a/app/models/encrypted_config.rb b/app/models/encrypted_config.rb
index 92e2d437..1217e51b 100644
--- a/app/models/encrypted_config.rb
+++ b/app/models/encrypted_config.rb
@@ -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
diff --git a/app/views/personalization_settings/_signature_request_sms_form.html.erb b/app/views/personalization_settings/_signature_request_sms_form.html.erb
new file mode 100644
index 00000000..88c81d8e
--- /dev/null
+++ b/app/views/personalization_settings/_signature_request_sms_form.html.erb
@@ -0,0 +1,31 @@
+
+
+
+
+ Signature request SMS
+
+
+
+ <% 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 %>
+
+ <%= 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' %>
+
+ Available variables: {account.name}, {submitter.link}, {submitter.name}, {submitter.first_name}, {submission.name}, {sender.name}.
+ Keep it short — carriers split messages over 160 characters into multiple billable segments.
+
+
+
+ <%= f.button button_title(title: t('save'), disabled_with: t('saving')), class: 'base-button' %>
+
+ <% end %>
+
+
diff --git a/app/views/personalization_settings/show.html.erb b/app/views/personalization_settings/show.html.erb
index 438da311..f4ba1639 100644
--- a/app/views/personalization_settings/show.html.erb
+++ b/app/views/personalization_settings/show.html.erb
@@ -8,6 +8,7 @@
<%= render 'signature_request_email_form' %>
<%= render 'documents_copy_email_form' %>
<%= render 'submitter_completed_email_form' %>
+ <%= render 'signature_request_sms_form' %>
<%= t('company_logo') %>
diff --git a/app/views/sms_settings/_placeholder.html.erb b/app/views/sms_settings/_placeholder.html.erb
deleted file mode 100644
index 863f1c1d..00000000
--- a/app/views/sms_settings/_placeholder.html.erb
+++ /dev/null
@@ -1,11 +0,0 @@
-
- <%= svg_icon('info_circle', class: 'w-6 h-6') %>
-
-
- <%= t('send_signature_requests_via_sms') %>
-
-
- 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.
-
-
-
diff --git a/app/views/sms_settings/index.html.erb b/app/views/sms_settings/index.html.erb
index ce3d516c..f572f851 100644
--- a/app/views/sms_settings/index.html.erb
+++ b/app/views/sms_settings/index.html.erb
@@ -2,7 +2,87 @@
<%= render 'shared/settings_nav' %>
SMS
- <%= render 'placeholder' %>
+
+ <% value = @encrypted_config.value || {} %>
+ <% sms_live = Sms.enabled_for?(current_account) %>
+
+ <% if sms_live %>
+
+ <%= svg_icon('discount_check_filled', class: 'w-6 h-6') %>
+
+
SMS is enabled
+
+ Provider: <%= value['provider'].to_s.upcase %>.
+ From: <%= value['from_number'] %>.
+
+
+
+ <% else %>
+
+ <%= svg_icon('info_circle', class: 'w-6 h-6') %>
+
+
SMS provider is not configured
+
+ WaboSign currently supports BulkVS . Paste the Basic Auth header value from the BulkVS portal below.
+
+
+
+ <% 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| %>
+
+
+ Enable SMS
+ <%= ff.check_box :enabled, { class: 'toggle', checked: value['enabled'] == true }, '1', '0' %>
+
+
+
+ <%= ff.label :provider, 'Provider', class: 'label' %>
+ <%= ff.select :provider, [['BulkVS', 'bulkvs']], { selected: value['provider'] || 'bulkvs' }, class: 'base-select' %>
+
+
+ <%= 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? %>
+ Leave blank to keep the saved token.
+ <% else %>
+ In the BulkVS portal, open the API tab and copy the pre-encoded Basic Auth header value (do not include "Basic ").
+ <% end %>
+
+
+ <%= ff.label :from_number, 'From Number', class: 'label' %>
+ <%= ff.text_field :from_number, value: value['from_number'], class: 'base-input', placeholder: '15551234567' %>
+ E.164 format (digits only, country code first; e.g. 15551234567).
+
+
+ <%= 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' %>
+ If set, BulkVS will POST delivery-status events here for each message.
+
+ <% end %>
+
+ <%= f.button button_title(title: t('save'), disabled_with: t('saving')), class: 'base-button' %>
+
+ <% end %>
+
+ <% if sms_live %>
+
+
+
Send a test SMS
+ <%= form_with url: test_message_settings_sms_path, method: :post, html: { autocomplete: 'off', class: 'space-y-3' } do |f| %>
+
+ Phone number
+
+ A short test message is sent to this number using your saved config.
+
+
+ Send test
+
+ <% end %>
+
+
+ <% end %>
diff --git a/app/views/submissions/_send_sms.html.erb b/app/views/submissions/_send_sms.html.erb
index 4637e3ac..f1622a4a 100644
--- a/app/views/submissions/_send_sms.html.erb
+++ b/app/views/submissions/_send_sms.html.erb
@@ -1,3 +1,11 @@
-
- <%= render 'sms_settings/placeholder' %>
-
+<% if Sms.enabled_for?(current_account) && local_assigns[:submitter]&.phone.present? %>
+
+
+
+ <%= local_assigns[:resend_sms] ? t('re_send_sms') : t('send_sms') %>
+ on save
+
+ <%= check_box_tag 'submitter[send_sms]', '1', local_assigns.fetch(:checked, false), id: 'send_sms_toggle', class: 'toggle' %>
+
+
+<% end %>
diff --git a/app/views/submissions/_send_sms_button.html.erb b/app/views/submissions/_send_sms_button.html.erb
index 178ad7cf..7017b1d0 100644
--- a/app/views/submissions/_send_sms_button.html.erb
+++ b/app/views/submissions/_send_sms_button.html.erb
@@ -1,7 +1,24 @@
-
-
- <%= submitter.sent_at? ? t('re_send_sms') : t('send_sms') %>
-
-
+ <% 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 %>
+
+
+ <%= t('send_sms') %>
+
+
+ <% end %>
+ <% else %>
+
+
+ <%= submitter.sent_at? ? t('re_send_sms') : t('send_sms') %>
+
+
+ <% end %>
diff --git a/config/routes.rb b/config/routes.rb
index ff676a3e..5bdad31d 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -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?
diff --git a/lib/sms.rb b/lib/sms.rb
new file mode 100644
index 00000000..d8f07533
--- /dev/null
+++ b/lib/sms.rb
@@ -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
diff --git a/lib/sms/providers/bulkvs.rb b/lib/sms/providers/bulkvs.rb
new file mode 100644
index 00000000..71ca5e60
--- /dev/null
+++ b/lib/sms/providers/bulkvs.rb
@@ -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
+ # Content-Type: application/json
+ # { "From": "", "To": ["", ...], "Message": "",
+ # "delivery_status_webhook_url": "" }
+ #
+ # 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