nuntius 1.5.2 → 1.6.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e838696ffee97488fa8ba197789b8c863a662dab896cb47c836b651d61135e50
4
- data.tar.gz: 695b8775db5700301419a9d25ab58f6752f16f1ac10451d31a4e2cb4a251a927
3
+ metadata.gz: 7180b584a94828e410a42d835438c6cf31515fc9b4dde511b094ba2b4b03490d
4
+ data.tar.gz: f1b864e4d6ccf58829584985c5eb2cac1135803780f542f8d604410b27ee0e69
5
5
  SHA512:
6
- metadata.gz: 5264de4adda58f8297b41d20bd2c3302f2022e2702e75f048f13dafed286ec7bb6c828ea59f6cf5f1d4b8106e2141d349194a67c71cd791b83f7dc65cb6a9508
7
- data.tar.gz: 5b3076c6c0a95a17f6f6f8b4be55a822bc3d66ed4bbd308b02970c2f8103589f637468d883832b94f695ad6f626f916b4512fe8507aea492e039a7f736a08b63
6
+ metadata.gz: 437e01c204f38b399332504ec8fa9179d7627973a87a25fa18739336d445b5212b7d53c02542f64445cd4b2a563280b46dcb0361f4e04fc10738da40510eaf81
7
+ data.tar.gz: 5b4ad9324541e2110b012c84e71c08760331220ed999763f566ad6ede6e63c38078018ea1af8d2bdf2bdd9e6353d24321d7324553188210a29d58584cd90247b
data/README.md CHANGED
@@ -223,7 +223,7 @@ The main key of the hash passed will also be the liquid variable.
223
223
 
224
224
  ### Mail
225
225
 
226
- Outbound mail is handled through SMTP. We've only exposed the HTML of mail, we can create text-versions based on sanitized HTML. HTML allows for [Foundation for Emails](https://get.foundation/emails/docs/index.html). You will have to include the CSS in the layouts.
226
+ Outbound mail is handled through SMTP (the `:smtp` provider on the `:mail` transport). LMTP delivery to a mailbox is available through a separate `:lmtp` transport (see below). We've only exposed the HTML of mail, we can create text-versions based on sanitized HTML. HTML allows for [Foundation for Emails](https://get.foundation/emails/docs/index.html). You will have to include the CSS in the layouts.
227
227
 
228
228
  In the layout you can add `<a href="{{message_url}}">Link to mail</a>` to provide a link to the online version of the message.
229
229
 
@@ -244,6 +244,28 @@ The subscription will automatically be confirmed.
244
244
 
245
245
  Next in AWS SES, configure the SNS topic to receive feedback notifications for Bounce, Complaint and Delivery and include original headers.
246
246
 
247
+ ### LMTP
248
+
249
+ LMTP (RFC 2033) delivers a message directly to a mailbox. It is its own transport, so it never interferes with `:mail`/SMTP delivery. Enable the transport and provider, and give the relevant templates `transport: "lmtp"`:
250
+
251
+ ```ruby
252
+ config.transport :lmtp
253
+ config.provider :lmtp, transport: :lmtp, settings: {
254
+ from_header: "Example <example@example.com>",
255
+ allow_list: ["example.com"]
256
+ }
257
+ ```
258
+
259
+ With LMTP both the recipient email address and the target LMTP server are provided by the template, inline-encoded in the `to` field as:
260
+
261
+ ```
262
+ <email>@<host>:<port>
263
+ ```
264
+
265
+ For example a template `to` of `{{user.email}}@mx.internal:24` renders to `user@example.com@mx.internal:24`. The provider uses the email (`user@example.com`) for the `To:` header and the envelope `RCPT TO`, and connects to the LMTP server at `mx.internal:24`. A plain address (without an inline `@host:port`) falls back to the optional `host`/`port` settings.
266
+
267
+ LMTP templates use the same fields as mail templates (subject, html, layout, tracking). Delivery is synchronous: the server confirms delivery in its reply, so the message is marked `delivered` straight away and no refresh is needed.
268
+
247
269
  ### Slack
248
270
 
249
271
  Slack uses the [postMessage](https://api.slack.com/methods/chat.postMessage) method to send messages. It will also upload any attachment the message has prior to sending the message itself.
@@ -1,14 +1,12 @@
1
1
 
2
- span.traffic-signal {
3
- &-red {
4
- @apply text-red-600;
5
- }
2
+ span.traffic-signal-red {
3
+ @apply text-red-600;
4
+ }
6
5
 
7
- &-green {
8
- @apply text-green-600;
9
- }
6
+ span.traffic-signal-green {
7
+ @apply text-green-600;
8
+ }
10
9
 
11
- &-orange {
12
- @apply text-orange-400;
13
- }
10
+ span.traffic-signal-orange {
11
+ @apply text-orange-400;
14
12
  }
@@ -0,0 +1,6 @@
1
+ /* Entry point for tailwindcss-rails v4 (Tailwind CSS v4).
2
+ The host app includes it with: @import "../builds/tailwind/nuntius";
3
+ Imported into the components layer so utility classes still override component styles, as they did in v3 */
4
+ @import "../../stylesheets/nuntius/application.css" layer(components);
5
+
6
+ @source "../../..";
@@ -3,7 +3,7 @@
3
3
  module Nuntius
4
4
  class TimebasedEventsJob < ApplicationJob
5
5
  def perform
6
- Nuntius::Template.where.not(interval: nil).each do |template|
6
+ Nuntius::Template.where(enabled: true).where.not(interval: nil).each do |template|
7
7
  messenger_class = Nuntius::BaseMessenger.messenger_for_class(template.klass)
8
8
 
9
9
  messenger_class.timebased_scope_for(template).each do |object|
@@ -9,14 +9,16 @@ module Nuntius
9
9
 
10
10
  define_callbacks :action, terminator: ->(_target, result_lambda) { result_lambda.call == false }
11
11
 
12
- attr_reader :attachments, :event, :object, :params
13
- attr_accessor :templates
12
+ attr_reader :attachments, :headers, :event, :object, :params
13
+ attr_accessor :templates, :smtp_envelope_from
14
14
 
15
15
  def initialize(object, event, params = {})
16
16
  @object = object
17
17
  @event = event
18
18
  @params = params
19
19
  @attachments = params.fetch(:attachments, [])
20
+ @headers = params.fetch(:headers, {})
21
+ @smtp_envelope_from = params[:smtp_envelope_from]
20
22
  end
21
23
 
22
24
  # Calls the event method on the messenger
@@ -39,6 +41,7 @@ module Nuntius
39
41
  @attachments.each do |attachment|
40
42
  msg.add_attachment(attachment)
41
43
  end
44
+ apply_mail_overrides(msg)
42
45
 
43
46
  transport = Nuntius::BaseTransport.class_from_name(template.transport).new
44
47
  transport.deliver(msg)
@@ -54,6 +57,12 @@ module Nuntius
54
57
  @attachments << attachment
55
58
  end
56
59
 
60
+ # Sets an extra header on the resulting message (currently applied by the
61
+ # mail transport). Useful for things like "Auto-Submitted" or "Precedence".
62
+ def header(key, value)
63
+ @headers[key.to_s] = value
64
+ end
65
+
57
66
  class << self
58
67
  #
59
68
  # Returns the variable name used in the liquid context
@@ -172,6 +181,19 @@ module Nuntius
172
181
 
173
182
  private
174
183
 
184
+ # Persists any messenger-provided mail overrides (extra headers and/or a
185
+ # custom SMTP envelope sender) onto the message so they survive the
186
+ # transport delivery job and can be applied by the provider.
187
+ def apply_mail_overrides(message)
188
+ return if @headers.blank? && @smtp_envelope_from.nil?
189
+
190
+ mail = {}
191
+ mail["headers"] = @headers if @headers.present?
192
+ mail["envelope_from"] = @smtp_envelope_from unless @smtp_envelope_from.nil?
193
+
194
+ message.metadata = (message.metadata || {}).merge("mail" => mail)
195
+ end
196
+
175
197
  # Returns the relevant templates for the object / event combination
176
198
  def select_templates
177
199
  return @templates if @templates
@@ -188,7 +210,7 @@ module Nuntius
188
210
 
189
211
  def liquid_context
190
212
  assigns = @params || {}
191
- instance_variables.reject { |i| %w[@params @object @locale @templates @template_scope].include? i.to_s }.each do |i|
213
+ instance_variables.reject { |i| %w[@params @object @locale @templates @template_scope @headers @smtp_envelope_from].include? i.to_s }.each do |i|
192
214
  assigns[i.to_s[1..]] = instance_variable_get(i)
193
215
  end
194
216
 
@@ -79,6 +79,14 @@ module Nuntius
79
79
  clicked_at.present?
80
80
  end
81
81
 
82
+ def opened?
83
+ opened_at.present?
84
+ end
85
+
86
+ def clicked?
87
+ clicked_at.present?
88
+ end
89
+
82
90
  # Removes only pending child messages
83
91
  def cleanup!
84
92
  Nuntius::Message.where(state: "pending").where(parent_message: self).destroy_all
@@ -0,0 +1,153 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "mail"
4
+
5
+ module Nuntius
6
+ # Shared delivery flow for mail providers (SMTP, LMTP). Concrete providers
7
+ # supply the recipient, the delivery method and the success semantics through
8
+ # the hooks below.
9
+ class AbstractMailProvider < BaseProvider
10
+ transport :mail
11
+
12
+ # RFC 5321 null reverse-path, used for auto-generated/bounce notifications.
13
+ NULL_REVERSE_PATH = "<>"
14
+
15
+ def deliver
16
+ return no_recipient if recipient.blank?
17
+ return block unless MailAllowList.new(settings[:allow_list]).allowed?(recipient)
18
+ return block if Nuntius::Message.where(state: %w[complaint bounced], to: recipient).count >= 1
19
+
20
+ mail = if message.from.present?
21
+ Mail.new(sender: from_header, from: message.from)
22
+ else
23
+ Mail.new(from: from_header)
24
+ end
25
+
26
+ mail.header["X-Nuntius-Message-Id"] = message.id
27
+
28
+ if message.campaign.present? && message.subscriber.present?
29
+ mail.header["List-Unsubscribe"] = "<" + message.subscriber.unsubscribe_link(message.campaign, message) + ">"
30
+ mail.header["List-Unsubscribe-Post"] = "List-Unsubscribe=One-Click"
31
+ end
32
+
33
+ null_sender = apply_mail_overrides(mail)
34
+
35
+ configure_delivery_method(mail, null_sender)
36
+
37
+ mail.to = recipient
38
+ mail.subject = message.subject
39
+ mail.part content_type: "multipart/alternative" do |p|
40
+ p.text_part = Mail::Part.new(
41
+ body: message.text,
42
+ content_type: "text/plain",
43
+ charset: "UTF-8"
44
+ )
45
+ if message.html.present?
46
+ # Apply email tracking (tracking pixel and link wrapping)
47
+ Nuntius::EmailTrackingService.perform(message: message)
48
+
49
+ message.html = message.html.gsub("{{message_url}}") { message_url(message) }
50
+
51
+ p.html_part = Mail::Part.new(
52
+ body: message.html,
53
+ content_type: "text/html",
54
+ charset: "UTF-8"
55
+ )
56
+ end
57
+ end
58
+
59
+ message.attachments.each do |attachment|
60
+ mail.attachments[attachment.filename.to_s] = {mime_type: attachment.content_type, content: attachment.download}
61
+ end
62
+
63
+ prepare_envelope(mail)
64
+
65
+ begin
66
+ response = mail.deliver!
67
+ rescue Net::SMTPFatalError
68
+ message.rejected
69
+ return message
70
+ rescue Net::SMTPServerBusy, Net::ReadTimeout
71
+ message.undelivered
72
+ return message
73
+ end
74
+
75
+ message.provider_id = mail.message_id
76
+ message.undelivered
77
+ message.send(success_state) if Rails.env.test? || delivered_successfully?(response)
78
+ message.last_sent_at = Time.zone.now if message.sent? || message.delivered?
79
+
80
+ message
81
+ end
82
+
83
+ def block
84
+ message.blocked
85
+ message
86
+ end
87
+
88
+ def no_recipient
89
+ message.no_recipient
90
+ message
91
+ end
92
+
93
+ private
94
+
95
+ # The resolved recipient email address. Used for the blank check, allow
96
+ # list, bounce dedup query and the +To:+ header. Subclasses may override
97
+ # (e.g. LMTP parses it out of the inline +to+).
98
+ def recipient
99
+ message.to
100
+ end
101
+
102
+ # Hook to set a custom SMTP/LMTP envelope. Default lets Mail derive it.
103
+ def prepare_envelope(mail)
104
+ end
105
+
106
+ # Concrete providers must configure how +mail+ is delivered.
107
+ def configure_delivery_method(mail, null_sender)
108
+ raise NotImplementedError, "#{self.class} must implement #configure_delivery_method"
109
+ end
110
+
111
+ # Whether a non-test delivery response counts as success.
112
+ def delivered_successfully?(response)
113
+ response.success?
114
+ end
115
+
116
+ # State to transition to on a successful delivery.
117
+ def success_state
118
+ :sent
119
+ end
120
+
121
+ # Applies any messenger-provided overrides stored on the message: extra
122
+ # headers and/or a custom SMTP envelope sender. Returns true when a null
123
+ # reverse-path (+MAIL FROM:<>+) was requested, so the caller can pick the
124
+ # delivery method that can actually emit it.
125
+ def apply_mail_overrides(mail)
126
+ overrides = message.metadata&.dig("mail")
127
+ return false if overrides.blank?
128
+
129
+ (overrides["headers"] || {}).each do |key, value|
130
+ mail.header[key] = value
131
+ end
132
+
133
+ return false unless overrides.key?("envelope_from")
134
+
135
+ envelope_from = overrides["envelope_from"]
136
+ if null_reverse_path?(envelope_from)
137
+ mail.smtp_envelope_from = NULL_REVERSE_PATH
138
+ true
139
+ else
140
+ mail.smtp_envelope_from = envelope_from
141
+ false
142
+ end
143
+ end
144
+
145
+ def null_reverse_path?(value)
146
+ value.blank? || value == NULL_REVERSE_PATH
147
+ end
148
+
149
+ def message_url(message)
150
+ Nuntius::Engine.routes.url_helpers.message_url(message.id, host: Nuntius.config.host(message))
151
+ end
152
+ end
153
+ end
@@ -0,0 +1,121 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "socket"
4
+ require "openssl"
5
+ require "net/smtp"
6
+
7
+ module Nuntius
8
+ # A minimal LMTP (RFC 2033) delivery method, usable as a Mail delivery
9
+ # method (+initialize(settings)+ + +deliver!(mail)+).
10
+ #
11
+ # Net::SMTP / Mail::SMTP greet with +EHLO+/+HELO+, but LMTP requires +LHLO+
12
+ # and returns one reply per recipient after the message body, so we speak the
13
+ # protocol directly over a socket. Failures raise the same Net::SMTP errors
14
+ # SMTP delivery would, so callers can treat both transports identically.
15
+ class LmtpDeliveryMethod
16
+ Response = Struct.new(:code, :message) do
17
+ def success?
18
+ code.to_s.start_with?("2")
19
+ end
20
+ end
21
+
22
+ DEFAULTS = {
23
+ address: "localhost",
24
+ port: 24,
25
+ ssl: false,
26
+ domain: "localhost"
27
+ }.freeze
28
+
29
+ attr_reader :settings
30
+
31
+ def initialize(settings = {})
32
+ @settings = DEFAULTS.merge(settings.compact)
33
+ end
34
+
35
+ def deliver!(mail)
36
+ envelope_from = mail.smtp_envelope_from
37
+ destinations = Array(mail.smtp_envelope_to)
38
+ raise ArgumentError, "LMTP delivery requires at least one recipient" if destinations.empty?
39
+
40
+ data = mail.encoded
41
+ socket = open_socket
42
+ begin
43
+ read_reply(socket, expected: "2") # 220 greeting
44
+ send_command(socket, "LHLO #{settings[:domain]}", expected: "2")
45
+ send_command(socket, "MAIL FROM:<#{envelope_from}>", expected: "2")
46
+ destinations.each do |to|
47
+ send_command(socket, "RCPT TO:<#{to}>", expected: "2")
48
+ end
49
+
50
+ send_command(socket, "DATA", expected: "3") # 354
51
+ socket.write(dot_stuff(data))
52
+ socket.write("\r\n") unless data.end_with?("\r\n")
53
+ socket.write(".\r\n")
54
+
55
+ # LMTP returns one reply per recipient after the body.
56
+ response = nil
57
+ destinations.each do
58
+ response = read_reply(socket, expected: "2")
59
+ end
60
+
61
+ send_command(socket, "QUIT", expected: nil)
62
+ response
63
+ ensure
64
+ socket.close unless socket.closed?
65
+ end
66
+ end
67
+
68
+ private
69
+
70
+ def open_socket
71
+ tcp = TCPSocket.open(settings[:address], settings[:port])
72
+ return tcp unless settings[:ssl]
73
+
74
+ ssl = OpenSSL::SSL::SSLSocket.new(tcp)
75
+ ssl.sync_close = true
76
+ ssl.connect
77
+ ssl
78
+ end
79
+
80
+ def send_command(socket, command, expected:)
81
+ socket.write("#{command}\r\n")
82
+ read_reply(socket, expected: expected)
83
+ end
84
+
85
+ def read_reply(socket, expected:)
86
+ lines = []
87
+ code = nil
88
+ loop do
89
+ line = socket.gets
90
+ raise Net::SMTPFatalError, "LMTP connection closed unexpectedly" if line.nil?
91
+
92
+ line = line.chomp
93
+ lines << line
94
+ code = line[0, 3]
95
+ # Multiline replies use "250-..." for every line but the last ("250 ...").
96
+ break unless line[3] == "-"
97
+ end
98
+
99
+ response = Response.new(code, lines.join("\n"))
100
+ return response if expected.nil? || code.to_s.start_with?(expected)
101
+
102
+ raise_for(code, response.message)
103
+ end
104
+
105
+ def raise_for(code, message)
106
+ case code.to_s[0]
107
+ when "4"
108
+ raise Net::SMTPServerBusy, message
109
+ when "5"
110
+ raise Net::SMTPFatalError, message
111
+ else
112
+ raise Net::SMTPUnknownError, message
113
+ end
114
+ end
115
+
116
+ # RFC 5321 transparency: lines starting with a dot get an extra dot.
117
+ def dot_stuff(data)
118
+ data.gsub(/^\./, "..")
119
+ end
120
+ end
121
+ end
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "mail"
4
+
5
+ module Nuntius
6
+ # Delivers mail to a mailbox over LMTP.
7
+ #
8
+ # The recipient and the per-message LMTP server are inline-encoded in the
9
+ # message +to+ as <tt><email>@<host>:<port></tt> (for example
10
+ # <tt>user@example.com@mx.internal:24</tt>). The +email+ is used for the
11
+ # +To:+ header and the envelope +RCPT TO+; the +host:port+ is the LMTP
12
+ # server we connect to. A plain address (without +@host:port+) falls back to
13
+ # the configured +host+/+port+ settings.
14
+ #
15
+ # Delivery is synchronous: the server's per-recipient reply is the final
16
+ # confirmation, so messages are marked +delivered+ directly and no refresh
17
+ # job is required.
18
+ class LmtpLmtpProvider < AbstractMailProvider
19
+ transport :lmtp
20
+
21
+ LMTP_TARGET = /\A(?<email>.+)@(?<host>[^@\s:]+):(?<port>\d+)\z/
22
+
23
+ setting_reader :from_header, required: true, description: "From header (example: Nuntius Messenger <nuntius@entdec.com>)"
24
+ setting_reader :host, required: false, default: nil, description: "Fallback LMTP host, used when not encoded in the recipient (example: 127.0.0.1)"
25
+ setting_reader :port, required: false, default: nil, description: "Fallback LMTP port, used when not encoded in the recipient (example: 24)"
26
+ setting_reader :allow_list, required: false, default: [], description: "Allow list (example: [example.com, tim@apple.com])"
27
+ setting_reader :ssl, required: false, default: false, description: "Whether to use SSL or not"
28
+
29
+ private
30
+
31
+ def recipient
32
+ lmtp_target[:email]
33
+ end
34
+
35
+ def prepare_envelope(mail)
36
+ mail.smtp_envelope_to = recipient
37
+ end
38
+
39
+ def configure_delivery_method(mail, _null_sender)
40
+ if Rails.env.test?
41
+ mail.delivery_method :test
42
+ else
43
+ mail.delivery_method Nuntius::LmtpDeliveryMethod, lmtp_settings
44
+ end
45
+ end
46
+
47
+ # LMTP delivery is synchronous and confirmed by the server, so we can mark
48
+ # the message delivered straight away (no refresh job needed).
49
+ def success_state
50
+ :delivered
51
+ end
52
+
53
+ def lmtp_settings
54
+ {
55
+ address: lmtp_target[:host],
56
+ port: lmtp_target[:port],
57
+ ssl: ssl
58
+ }
59
+ end
60
+
61
+ def lmtp_target
62
+ @lmtp_target ||= begin
63
+ match = message.to.to_s.strip.match(LMTP_TARGET)
64
+ if match
65
+ {email: match[:email], host: match[:host], port: match[:port].to_i}
66
+ else
67
+ {email: message.to, host: host, port: port}
68
+ end
69
+ end
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "mail"
4
+
5
+ module Nuntius
6
+ # A Mail::SMTP delivery method that sends with a null reverse-path
7
+ # (+MAIL FROM:<>+).
8
+ #
9
+ # The stock Mail::SmtpEnvelope refuses to emit an empty reverse-path
10
+ # (it raises on a blank From), and Net::SMTP wraps the address in angle
11
+ # brackets, so setting +smtp_envelope_from+ to +"<>"+ would produce the
12
+ # invalid +MAIL FROM:<<>>+. This delivery method bypasses Mail::SmtpEnvelope
13
+ # and hands an empty reverse-path straight to Net::SMTP, yielding the
14
+ # RFC 5321 null sender used by auto-generated/bounce notifications.
15
+ class NullSenderSmtp < ::Mail::SMTP
16
+ def deliver!(mail)
17
+ response = start_smtp_session do |smtp|
18
+ smtp.sendmail(mail.encoded, "", mail.smtp_envelope_to)
19
+ end
20
+
21
+ settings[:return_response] ? response : self
22
+ end
23
+ end
24
+ end
@@ -3,7 +3,7 @@
3
3
  require "mail"
4
4
 
5
5
  module Nuntius
6
- class SmtpMailProvider < BaseProvider
6
+ class SmtpMailProvider < AbstractMailProvider
7
7
  transport :mail
8
8
 
9
9
  setting_reader :from_header, required: true, description: "From header (example: Nuntius Messenger <nuntius@entdec.com>)"
@@ -14,94 +14,27 @@ module Nuntius
14
14
  setting_reader :allow_list, required: false, default: [], description: "Allow list (example: [example.com, tim@apple.com])"
15
15
  setting_reader :ssl, required: false, default: false, description: "Whether to use SSL or not"
16
16
 
17
- def deliver
18
- return no_recipient if message.to.blank?
19
- return block unless MailAllowList.new(settings[:allow_list]).allowed?(message.to)
20
- return block if Nuntius::Message.where(state: %w[complaint bounced], to: message.to).count >= 1
21
-
22
- mail = if message.from.present?
23
- Mail.new(sender: from_header, from: message.from)
24
- else
25
- Mail.new(from: from_header)
26
- end
27
-
28
- mail.header["X-Nuntius-Message-Id"] = message.id
29
-
30
- if message.campaign.present? && message.subscriber.present?
31
- mail.header["List-Unsubscribe"] = "<" + message.subscriber.unsubscribe_link(message.campaign, message) + ">"
32
- mail.header["List-Unsubscribe-Post"] = "List-Unsubscribe=One-Click"
33
- end
17
+ private
34
18
 
19
+ def configure_delivery_method(mail, null_sender)
35
20
  if Rails.env.test?
36
21
  mail.delivery_method :test
22
+ elsif null_sender
23
+ mail.delivery_method Nuntius::NullSenderSmtp, smtp_settings
37
24
  else
38
- mail.delivery_method :smtp,
39
- address: host,
40
- port: port,
41
- user_name: username,
42
- password: password,
43
- return_response: true,
44
- ssl: ssl
45
- end
46
-
47
- mail.to = message.to
48
- mail.subject = message.subject
49
- mail.part content_type: "multipart/alternative" do |p|
50
- p.text_part = Mail::Part.new(
51
- body: message.text,
52
- content_type: "text/plain",
53
- charset: "UTF-8"
54
- )
55
- if message.html.present?
56
- # Apply email tracking (tracking pixel and link wrapping)
57
- Nuntius::EmailTrackingService.perform(message: message)
58
-
59
- message.html = message.html.gsub("{{message_url}}") { message_url(message) }
60
-
61
- p.html_part = Mail::Part.new(
62
- body: message.html,
63
- content_type: "text/html",
64
- charset: "UTF-8"
65
- )
66
- end
25
+ mail.delivery_method :smtp, smtp_settings
67
26
  end
68
-
69
- message.attachments.each do |attachment|
70
- mail.attachments[attachment.filename.to_s] = {mime_type: attachment.content_type, content: attachment.download}
71
- end
72
-
73
- begin
74
- response = mail.deliver!
75
- rescue Net::SMTPFatalError
76
- message.rejected
77
- return message
78
- rescue Net::SMTPServerBusy, Net::ReadTimeout
79
- message.undelivered
80
- return message
81
- end
82
-
83
- message.provider_id = mail.message_id
84
- message.undelivered
85
- message.sent if Rails.env.test? || response.success?
86
- message.last_sent_at = Time.zone.now if message.sent?
87
-
88
- message
89
27
  end
90
28
 
91
- def block
92
- message.blocked
93
- message
94
- end
95
-
96
- def no_recipient
97
- message.no_recipient
98
- message
99
- end
100
-
101
- private
102
-
103
- def message_url(message)
104
- Nuntius::Engine.routes.url_helpers.message_url(message.id, host: Nuntius.config.host(message))
29
+ def smtp_settings
30
+ {
31
+ address: host,
32
+ port: port,
33
+ user_name: username,
34
+ password: password,
35
+ return_response: true,
36
+ ssl: ssl
37
+ }
105
38
  end
106
39
  end
107
40
  end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Nuntius
4
+ # LMTP delivers actual email, so it shares all mail processing (premailer,
5
+ # per-recipient splitting) with MailTransport. Only the configured providers
6
+ # differ: the :lmtp transport routes to the LMTP provider.
7
+ class LmtpTransport < MailTransport
8
+ end
9
+ end
@@ -61,6 +61,18 @@
61
61
  .col-span-12
62
62
  = f.input :html, as: :editor, mode: 'text/markdown', height: '400px'
63
63
 
64
+ template data-toggle-target='toggleable' data-toggle-value='lmtp'
65
+ .col-span-6
66
+ = f.input :link_tracking, as: :switch
67
+ .col-span-6
68
+ = f.input :open_tracking, as: :switch
69
+ .col-span-12
70
+ = f.input :subject, as: :editor, mode: 'text/plain'
71
+ .col-span-12
72
+ = f.association :layout, collection: @layouts, include_blank: true
73
+ .col-span-12
74
+ = f.input :html, as: :editor, mode: 'text/markdown', height: '400px'
75
+
64
76
  template data-toggle-target='toggleable' data-toggle-value='voice'
65
77
  .col-span-12
66
78
  = f.input :text, as: :editor, mode: 'text/plain', height: '400px'
@@ -7,6 +7,8 @@ module Nuntius
7
7
  desc "Configures tailwind.config.js and application.tailwindcss.css"
8
8
 
9
9
  def add_content_to_tailwind_config
10
+ return unless File.exist?(File.join(destination_root, "config/tailwind.config.js"))
11
+
10
12
  inject_into_file "config/tailwind.config.js", before: "],\n theme: {" do
11
13
  " // Nuntius content\n" +
12
14
  %w[/app/views/**/* /app/helpers/**/* /app/controllers/**/* /app/components/**/* /app/javascript/**/*.js /app/assets/**/*.css].map { |path| " \"#{Nuntius::Engine.root}#{path}\"" }.join(",\n") +
@@ -15,6 +17,8 @@ module Nuntius
15
17
  end
16
18
 
17
19
  def add_content_application_tailwind_css
20
+ return unless File.exist?(File.join(destination_root, "app/assets/stylesheets/application.tailwind.css"))
21
+
18
22
  inject_into_file "app/assets/stylesheets/application.tailwind.css", before: "@tailwind base;" do
19
23
  "@import '#{Nuntius::Engine.root}/app/assets/stylesheets/nuntius/application.css';\n"
20
24
  end
@@ -21,6 +21,17 @@ Nuntius.setup do |config|
21
21
  }
22
22
  }
23
23
 
24
+ # LMTP delivery to a mailbox, on its own `:lmtp` transport. Enable the
25
+ # transport and provider, then give such templates `transport: "lmtp"`. The
26
+ # recipient and per-message LMTP server are inline-encoded in the template
27
+ # `to` as `<email>@<host>:<port>`, e.g. `user@example.com@mx.internal:24`.
28
+ # `host`/`port` here are optional fallbacks for plain addresses.
29
+ # config.transport :lmtp
30
+ # config.provider :lmtp, transport: :lmtp, settings: {
31
+ # from_header: "Example <example@example.com>",
32
+ # allow_list: ["example.com"]
33
+ # }
34
+
24
35
  config.provider :houston, transport: :push, settings: {certificate: ""}
25
36
  config.provider :firebase, transport: :push, settings: {server_key: ""}
26
37
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Nuntius
4
- VERSION = "1.5.2"
4
+ VERSION = "1.6.0"
5
5
  end
@@ -7,7 +7,9 @@ namespace :nuntius do
7
7
  end
8
8
  end
9
9
 
10
- if Rake::Task.task_defined?("tailwindcss:build")
10
+ # tailwindcss-rails v4 picks up app/assets/tailwind/nuntius/engine.css itself (via tailwindcss:engines),
11
+ # so only hook in the v3 config generator when that task is absent.
12
+ if Rake::Task.task_defined?("tailwindcss:build") && !Rake::Task.task_defined?("tailwindcss:engines")
11
13
  Rake::Task["tailwindcss:build"].enhance(["nuntius:tailwindcss:config"])
12
14
  Rake::Task["tailwindcss:watch"].enhance(["nuntius:tailwindcss:config"])
13
15
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: nuntius
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.5.2
4
+ version: 1.6.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Tom de Grunt
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-06-20 00:00:00.000000000 Z
11
+ date: 2026-10-16 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: apnotic
@@ -429,6 +429,7 @@ files:
429
429
  - README.md
430
430
  - Rakefile
431
431
  - app/assets/stylesheets/nuntius/application.css
432
+ - app/assets/tailwind/nuntius/engine.css
432
433
  - app/controllers/nuntius/admin/campaigns_controller.rb
433
434
  - app/controllers/nuntius/admin/layouts_controller.rb
434
435
  - app/controllers/nuntius/admin/lists/subscribers_controller.rb
@@ -486,10 +487,14 @@ files:
486
487
  - app/models/nuntius/template.rb
487
488
  - app/presenters/application_presenter.rb
488
489
  - app/presenters/template_presenter.rb
490
+ - app/providers/nuntius/abstract_mail_provider.rb
489
491
  - app/providers/nuntius/apnotic_push_provider.rb
490
492
  - app/providers/nuntius/base_provider.rb
491
493
  - app/providers/nuntius/firebase_push_provider.rb
494
+ - app/providers/nuntius/lmtp_delivery_method.rb
495
+ - app/providers/nuntius/lmtp_lmtp_provider.rb
492
496
  - app/providers/nuntius/message_bird_sms_provider.rb
497
+ - app/providers/nuntius/null_sender_smtp.rb
493
498
  - app/providers/nuntius/slack_slack_provider.rb
494
499
  - app/providers/nuntius/smstools_sms_provider.rb
495
500
  - app/providers/nuntius/smtp_mail_provider.rb
@@ -513,6 +518,7 @@ files:
513
518
  - app/tables/nuntius/subscribers_table.rb
514
519
  - app/tables/nuntius/templates_table.rb
515
520
  - app/transports/nuntius/base_transport.rb
521
+ - app/transports/nuntius/lmtp_transport.rb
516
522
  - app/transports/nuntius/mail_transport.rb
517
523
  - app/transports/nuntius/push_transport.rb
518
524
  - app/transports/nuntius/slack_transport.rb