diff --git a/packages/zammad-addon-bridge/src/app/assets/javascripts/app/controllers/_profile/signal_notifications.coffee b/packages/zammad-addon-bridge/src/app/assets/javascripts/app/controllers/_profile/signal_notifications.coffee
new file mode 100644
index 0000000..23d0df7
--- /dev/null
+++ b/packages/zammad-addon-bridge/src/app/assets/javascripts/app/controllers/_profile/signal_notifications.coffee
@@ -0,0 +1,89 @@
+class ProfileSignalNotifications extends App.ControllerSubContent
+ @requiredPermission: 'user_preferences.signal_notifications+ticket.agent'
+ header: __('Signal Notifications')
+ events:
+ 'submit form': 'update'
+
+ constructor: ->
+ super
+ App.User.full(App.Session.get().id, @render, true, true)
+
+ render: =>
+ config =
+ enabled: false
+ events:
+ create: true
+ update: true
+ escalation: true
+ reminder_reached: true
+
+ user = App.User.find(App.Session.get().id)
+ user_config = user.preferences?.signal_notifications
+ if user_config
+ config = $.extend(true, {}, config, user_config)
+
+ @html App.view('profile/signal_notifications')
+ config: config
+ signal_uid: user.signal_uid || ''
+ signal_notification_enabled: App.Config.get('signal_notification_enabled')
+
+ update: (e) =>
+ e.preventDefault()
+ params = @formParam(e.target)
+
+ preferences = {}
+ preferences.signal_notifications =
+ enabled: params.enabled == 'true'
+ events:
+ create: params.event_create == 'true'
+ update: params.event_update == 'true'
+ escalation: params.event_escalation == 'true'
+ reminder_reached: params.event_reminder_reached == 'true'
+
+ @formDisable(e)
+
+ @ajax(
+ id: 'preferences_signal_notifications'
+ type: 'PUT'
+ url: @apiPath + '/users/preferences'
+ data: JSON.stringify(preferences)
+ processData: true
+ success: @successPreferences
+ error: @error
+ )
+
+ if params.signal_uid?
+ user = App.User.find(App.Session.get().id)
+ user.signal_uid = params.signal_uid
+ user.save(
+ done: =>
+ # User saved successfully
+ fail: (settings, details) =>
+ @notify(
+ type: 'error'
+ msg: details.error || __('Failed to save Signal phone number')
+ )
+ )
+
+ successPreferences: (data, status, xhr) =>
+ App.User.full(
+ App.Session.get('id'),
+ =>
+ App.Event.trigger('ui:rerender')
+ @notify(
+ type: 'success'
+ msg: __('Update successful.')
+ )
+ ,
+ true
+ )
+
+ error: (xhr, status, error) =>
+ @render()
+ data = JSON.parse(xhr.responseText)
+ @notify(
+ type: 'error'
+ msg: data.message
+ )
+
+App.Config.set('SignalNotifications', { prio: 2650, name: __('Signal Notifications'), parent: '#profile', target: '#profile/signal_notifications', permission: ['user_preferences.signal_notifications+ticket.agent'], controller: ProfileSignalNotifications }, 'NavBarProfile')
diff --git a/packages/zammad-addon-bridge/src/app/assets/javascripts/app/views/profile/signal_notifications.jst.eco b/packages/zammad-addon-bridge/src/app/assets/javascripts/app/views/profile/signal_notifications.jst.eco
new file mode 100644
index 0000000..4899934
--- /dev/null
+++ b/packages/zammad-addon-bridge/src/app/assets/javascripts/app/views/profile/signal_notifications.jst.eco
@@ -0,0 +1,86 @@
+
+
+<% if !@signal_notification_enabled: %>
+
+ <%- @T('Signal notifications are currently disabled by the administrator.') %>
+
+<% end %>
+
+
diff --git a/packages/zammad-addon-bridge/src/app/jobs/signal_notification_job.rb b/packages/zammad-addon-bridge/src/app/jobs/signal_notification_job.rb
new file mode 100644
index 0000000..1da1012
--- /dev/null
+++ b/packages/zammad-addon-bridge/src/app/jobs/signal_notification_job.rb
@@ -0,0 +1,63 @@
+# frozen_string_literal: true
+
+class SignalNotificationJob < ApplicationJob
+ retry_on StandardError, attempts: 3, wait: lambda { |executions|
+ executions * 60.seconds
+ }
+
+ def perform(ticket_id:, article_id:, user_id:, type:, changes:)
+ ticket = Ticket.find_by(id: ticket_id)
+ return if !ticket
+
+ user = User.find_by(id: user_id)
+ return if !user
+ return if user.signal_uid.blank?
+
+ article = article_id ? Ticket::Article.find_by(id: article_id) : nil
+
+ channel = signal_channel
+ return if !channel
+
+ message = SignalNotificationSender.build_message(
+ ticket: ticket,
+ article: article,
+ user: user,
+ type: type,
+ changes: changes
+ )
+
+ return if message.blank?
+
+ SignalNotificationSender.send_message(
+ channel: channel,
+ recipient: user.signal_uid,
+ message: message
+ )
+
+ add_history(ticket, user, type)
+
+ Rails.logger.info "Sent Signal notification to #{user.signal_uid} for ticket ##{ticket.number} (#{type})"
+ end
+
+ private
+
+ def signal_channel
+ channel_id = Setting.get('signal_notification_channel_id')
+ return unless channel_id
+
+ Channel.find_by(id: channel_id, area: 'Signal::Account', active: true)
+ end
+
+ def add_history(ticket, user, type)
+ identifier = user.signal_uid.presence || user.login
+ recipient_list = "#{identifier}(#{type}:signal)"
+
+ History.add(
+ o_id: ticket.id,
+ history_type: 'notification',
+ history_object: 'Ticket',
+ value_to: recipient_list,
+ created_by_id: 1
+ )
+ end
+end
diff --git a/packages/zammad-addon-bridge/src/app/models/transaction/signal_notification.rb b/packages/zammad-addon-bridge/src/app/models/transaction/signal_notification.rb
new file mode 100644
index 0000000..a7b9f29
--- /dev/null
+++ b/packages/zammad-addon-bridge/src/app/models/transaction/signal_notification.rb
@@ -0,0 +1,144 @@
+# frozen_string_literal: true
+
+class Transaction::SignalNotification
+ include ChecksHumanChanges
+
+ def initialize(item, params = {})
+ @item = item
+ @params = params
+ end
+
+ def perform
+ return if Setting.get('import_mode')
+ return if %w[Ticket Ticket::Article].exclude?(@item[:object])
+ return if @params[:disable_notification]
+ return if !ticket
+ return if !signal_notifications_enabled?
+ return if !signal_channel
+
+ collect_signal_recipients.each do |user|
+ SignalNotificationJob.perform_later(
+ ticket_id: ticket.id,
+ article_id: @item[:article_id],
+ user_id: user.id,
+ type: @item[:type],
+ changes: human_changes(@item[:changes], ticket, user)
+ )
+ end
+ end
+
+ private
+
+ def ticket
+ @ticket ||= Ticket.find_by(id: @item[:object_id])
+ end
+
+ def article
+ return if !@item[:article_id]
+
+ @article ||= begin
+ art = Ticket::Article.find_by(id: @item[:article_id])
+ return unless art
+
+ sender = Ticket::Article::Sender.lookup(id: art.sender_id)
+ if sender&.name == 'System'
+ return if @item[:changes].blank? && art.preferences[:notification] != true
+ return if art.preferences[:notification] != true
+ end
+
+ art
+ end
+ end
+
+ def current_user
+ @current_user ||= User.lookup(id: @item[:user_id]) || User.lookup(id: 1)
+ end
+
+ def signal_notifications_enabled?
+ Setting.get('signal_notification_enabled') == true
+ end
+
+ def signal_channel
+ @signal_channel ||= begin
+ channel_id = Setting.get('signal_notification_channel_id')
+ return unless channel_id
+
+ Channel.find_by(id: channel_id, area: 'Signal::Account', active: true)
+ end
+ end
+
+ def collect_signal_recipients
+ recipients = []
+
+ possible_recipients = possible_recipients_of_group(ticket.group_id)
+
+ mention_users = Mention.where(mentionable_type: @item[:object], mentionable_id: @item[:object_id]).map(&:user)
+ mention_users.each do |user|
+ next if !user.group_access?(ticket.group_id, 'read')
+
+ possible_recipients.push(user)
+ end
+
+ if ticket.owner_id != 1
+ possible_recipients.push(ticket.owner)
+ end
+
+ possible_recipients_with_ooo = Set.new(possible_recipients)
+ possible_recipients.each do |user|
+ add_out_of_office_replacement(user, possible_recipients_with_ooo)
+ end
+
+ possible_recipients_with_ooo.each do |user|
+ next if recipient_is_current_user?(user)
+ next if !user.active?
+ next if !user_has_signal_notifications_enabled?(user)
+ next if user.signal_uid.blank?
+ next if !should_notify_for_event?(user)
+
+ recipients.push(user)
+ end
+
+ recipients.uniq(&:id)
+ end
+
+ def possible_recipients_of_group(group_id)
+ Rails.cache.fetch("User/signal_notification/possible_recipients_of_group/#{group_id}/#{User.latest_change}", expires_in: 20.seconds) do
+ User.group_access(group_id, 'full').sort_by(&:login)
+ end
+ end
+
+ def add_out_of_office_replacement(user, recipients)
+ replacement = user.out_of_office_agent
+ return unless replacement
+ return unless TicketPolicy.new(replacement, ticket).agent_read_access?
+
+ recipients.add(replacement)
+ end
+
+ def recipient_is_current_user?(user)
+ return false if @params[:interface_handle] != 'application_server'
+ return true if article&.updated_by_id == user.id
+ return true if !article && @item[:user_id] == user.id
+
+ false
+ end
+
+ def user_has_signal_notifications_enabled?(user)
+ user.preferences.dig('signal_notifications', 'enabled') == true
+ end
+
+ def should_notify_for_event?(user)
+ event_type = @item[:type]
+ return false if event_type.blank?
+
+ event_key = case event_type
+ when 'create' then 'create'
+ when 'update', 'update.merged_into', 'update.received_merge', 'update.reaction' then 'update'
+ when 'reminder_reached' then 'reminder_reached'
+ when 'escalation', 'escalation_warning' then 'escalation'
+ else return false
+ end
+
+ user.preferences.dig('signal_notifications', 'events', event_key) == true
+ end
+end
diff --git a/packages/zammad-addon-bridge/src/app/views/signal_notification/ticket_create/en.txt.erb b/packages/zammad-addon-bridge/src/app/views/signal_notification/ticket_create/en.txt.erb
new file mode 100644
index 0000000..9a12210
--- /dev/null
+++ b/packages/zammad-addon-bridge/src/app/views/signal_notification/ticket_create/en.txt.erb
@@ -0,0 +1,16 @@
+[Ticket #<%= ticket.number %>] <%= ticket.title %>
+
+NEW TICKET
+
+Group: <%= ticket.group.name %>
+Owner: <%= ticket.owner.fullname %>
+State: <%= t(ticket.state.name) %>
+Priority: <%= t(ticket.priority.name) %>
+Customer: <%= ticket.customer.fullname %>
+Created by: <%= current_user.fullname %>
+<% if article -%>
+
+<%= article_body_preview(500) %>
+<% end -%>
+
+<%= ticket_url %>
diff --git a/packages/zammad-addon-bridge/src/app/views/signal_notification/ticket_escalation/en.txt.erb b/packages/zammad-addon-bridge/src/app/views/signal_notification/ticket_escalation/en.txt.erb
new file mode 100644
index 0000000..a3fdbaa
--- /dev/null
+++ b/packages/zammad-addon-bridge/src/app/views/signal_notification/ticket_escalation/en.txt.erb
@@ -0,0 +1,11 @@
+[Ticket #<%= ticket.number %>] <%= ticket.title %>
+
+ESCALATION
+
+Group: <%= ticket.group.name %>
+Owner: <%= ticket.owner.fullname %>
+State: <%= t(ticket.state.name) %>
+Priority: <%= t(ticket.priority.name) %>
+Customer: <%= ticket.customer.fullname %>
+
+<%= ticket_url %>
diff --git a/packages/zammad-addon-bridge/src/app/views/signal_notification/ticket_reminder_reached/en.txt.erb b/packages/zammad-addon-bridge/src/app/views/signal_notification/ticket_reminder_reached/en.txt.erb
new file mode 100644
index 0000000..e718346
--- /dev/null
+++ b/packages/zammad-addon-bridge/src/app/views/signal_notification/ticket_reminder_reached/en.txt.erb
@@ -0,0 +1,10 @@
+[Ticket #<%= ticket.number %>] <%= ticket.title %>
+
+REMINDER REACHED
+
+Group: <%= ticket.group.name %>
+Owner: <%= ticket.owner.fullname %>
+State: <%= t(ticket.state.name) %>
+Pending till: <%= ticket.pending_time&.strftime('%Y-%m-%d %H:%M') %>
+
+<%= ticket_url %>
diff --git a/packages/zammad-addon-bridge/src/app/views/signal_notification/ticket_update/en.txt.erb b/packages/zammad-addon-bridge/src/app/views/signal_notification/ticket_update/en.txt.erb
new file mode 100644
index 0000000..3e87f33
--- /dev/null
+++ b/packages/zammad-addon-bridge/src/app/views/signal_notification/ticket_update/en.txt.erb
@@ -0,0 +1,14 @@
+[Ticket #<%= ticket.number %>] <%= ticket.title %>
+
+TICKET UPDATED by <%= current_user.fullname %>
+<% if changes.present? -%>
+
+Changes:
+<%= changes_summary %>
+<% end -%>
+<% if article -%>
+
+<%= article_body_preview(500) %>
+<% end -%>
+
+<%= ticket_url_with_article %>
diff --git a/packages/zammad-addon-bridge/src/db/addon/bridge/20260120000001_add_signal_notification_settings.rb b/packages/zammad-addon-bridge/src/db/addon/bridge/20260120000001_add_signal_notification_settings.rb
new file mode 100644
index 0000000..9e1bf23
--- /dev/null
+++ b/packages/zammad-addon-bridge/src/db/addon/bridge/20260120000001_add_signal_notification_settings.rb
@@ -0,0 +1,76 @@
+# frozen_string_literal: true
+
+class AddSignalNotificationSettings < ActiveRecord::Migration[5.2]
+ def self.up
+ # Register Signal notification transaction backend
+ # Using 0105 to run after email notifications (0100)
+ Setting.create_if_not_exists(
+ title: 'Defines transaction backend.',
+ name: '0105_signal_notification',
+ area: 'Transaction::Backend::Async',
+ description: 'Defines the transaction backend to send Signal notifications.',
+ options: {},
+ state: 'Transaction::SignalNotification',
+ frontend: false
+ )
+
+ # Global enable/disable for Signal notifications
+ Setting.create_if_not_exists(
+ title: 'Signal Notifications',
+ name: 'signal_notification_enabled',
+ area: 'Integration::Switch',
+ description: 'Enable or disable Signal notifications for agents.',
+ options: {
+ form: [
+ {
+ display: '',
+ null: true,
+ name: 'signal_notification_enabled',
+ tag: 'boolean',
+ options: {
+ true => 'yes',
+ false => 'no',
+ },
+ },
+ ],
+ },
+ state: false,
+ preferences: {
+ prio: 1,
+ permission: ['admin.integration'],
+ },
+ frontend: true
+ )
+
+ # Which Signal channel/bot to use for sending notifications
+ Setting.create_if_not_exists(
+ title: 'Signal Notification Channel',
+ name: 'signal_notification_channel_id',
+ area: 'Integration::SignalNotification',
+ description: 'The Signal channel (bot) used to send notifications to agents.',
+ options: {},
+ state: nil,
+ preferences: {
+ prio: 2,
+ permission: ['admin.integration'],
+ },
+ frontend: false
+ )
+
+ # Permission for Signal notifications profile page
+ Permission.create_if_not_exists(
+ name: 'user_preferences.signal_notifications',
+ description: 'Manage Signal notification preferences',
+ preferences: {
+ translations: ['Profile - Signal Notifications']
+ }
+ )
+ end
+
+ def self.down
+ Setting.find_by(name: '0105_signal_notification')&.destroy
+ Setting.find_by(name: 'signal_notification_enabled')&.destroy
+ Setting.find_by(name: 'signal_notification_channel_id')&.destroy
+ Permission.find_by(name: 'user_preferences.signal_notifications')&.destroy
+ end
+end
diff --git a/packages/zammad-addon-bridge/src/lib/signal_notification_sender.rb b/packages/zammad-addon-bridge/src/lib/signal_notification_sender.rb
new file mode 100644
index 0000000..8528892
--- /dev/null
+++ b/packages/zammad-addon-bridge/src/lib/signal_notification_sender.rb
@@ -0,0 +1,139 @@
+# frozen_string_literal: true
+
+require 'erb'
+
+class SignalNotificationSender
+ TEMPLATE_DIR = Rails.root.join('app', 'views', 'signal_notification')
+
+ class << self
+ def build_message(ticket:, article:, user:, type:, changes:)
+ template_name = template_for_type(type)
+ return if template_name.blank?
+
+ locale = user.locale || Setting.get('locale_default') || 'en'
+ template_path = find_template(template_name, locale)
+ return if template_path.blank?
+
+ render_template(template_path, binding_for(ticket, article, user, changes))
+ end
+
+ def send_message(channel:, recipient:, message:)
+ return if Rails.env.test?
+ return if channel.blank?
+ return if recipient.blank?
+ return if message.blank?
+
+ api_url = channel.options[:api_url]
+ api_token = channel.options[:api_token]
+
+ return if api_url.blank? || api_token.blank?
+
+ api = CdrSignalApi.new(api_url, api_token)
+ api.send_message(recipient, message)
+ end
+
+ private
+
+ def template_for_type(type)
+ case type
+ when 'create'
+ 'ticket_create'
+ when 'update', 'update.merged_into', 'update.received_merge', 'update.reaction'
+ 'ticket_update'
+ when 'reminder_reached'
+ 'ticket_reminder_reached'
+ when 'escalation', 'escalation_warning'
+ 'ticket_escalation'
+ end
+ end
+
+ def find_template(template_name, locale)
+ base_locale = locale.split('-').first
+
+ [locale, base_locale, 'en'].uniq.each do |try_locale|
+ path = TEMPLATE_DIR.join(template_name, "#{try_locale}.txt.erb")
+ return path if File.exist?(path)
+ end
+
+ nil
+ end
+
+ def binding_for(ticket, article, user, changes)
+ TemplateContext.new(
+ ticket: ticket,
+ article: article,
+ user: user,
+ changes: changes,
+ config: {
+ http_type: Setting.get('http_type'),
+ fqdn: Setting.get('fqdn'),
+ product_name: Setting.get('product_name')
+ }
+ ).get_binding
+ end
+
+ def render_template(template_path, binding)
+ template = File.read(template_path)
+ erb = ERB.new(template, trim_mode: '-')
+ erb.result(binding).strip
+ end
+ end
+
+ class TemplateContext
+ attr_reader :ticket, :article, :recipient, :changes, :config
+
+ def initialize(ticket:, article:, user:, changes:, config:)
+ @ticket = ticket
+ @article = article
+ @recipient = user
+ @changes = changes
+ @config = OpenStruct.new(config)
+ end
+
+ def get_binding
+ binding
+ end
+
+ def ticket_url
+ "#{config.http_type}://#{config.fqdn}/#ticket/zoom/#{ticket.id}"
+ end
+
+ def ticket_url_with_article
+ if article
+ "#{ticket_url}/#{article.id}"
+ else
+ ticket_url
+ end
+ end
+
+ def current_user
+ @current_user ||= User.lookup(id: ticket.updated_by_id) || User.lookup(id: 1)
+ end
+
+ def changes_summary
+ return '' if changes.blank?
+
+ changes.map { |key, values| "#{key}: #{values[0]} -> #{values[1]}" }.join("\n")
+ end
+
+ def article_body_preview(max_length = 500)
+ return '' unless article
+ return '' if article.body.blank?
+
+ body = article.body.to_s
+ body = ActionController::Base.helpers.strip_tags(body) if article.content_type&.include?('html')
+ body = body.gsub(/\s+/, ' ').strip
+
+ if body.length > max_length
+ "#{body[0, max_length]}..."
+ else
+ body
+ end
+ end
+
+ def t(text)
+ locale = recipient.locale || Setting.get('locale_default') || 'en'
+ Translation.translate(locale, text)
+ end
+ end
+end