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 +4 -4
- data/README.md +23 -1
- data/app/assets/stylesheets/nuntius/application.css +8 -10
- data/app/assets/tailwind/nuntius/engine.css +6 -0
- data/app/jobs/nuntius/timebased_events_job.rb +1 -1
- data/app/messengers/nuntius/base_messenger.rb +25 -3
- data/app/models/nuntius/message.rb +8 -0
- data/app/providers/nuntius/abstract_mail_provider.rb +153 -0
- data/app/providers/nuntius/lmtp_delivery_method.rb +121 -0
- data/app/providers/nuntius/lmtp_lmtp_provider.rb +72 -0
- data/app/providers/nuntius/null_sender_smtp.rb +24 -0
- data/app/providers/nuntius/smtp_mail_provider.rb +15 -82
- data/app/transports/nuntius/lmtp_transport.rb +9 -0
- data/app/views/nuntius/admin/templates/edit.html.slim +12 -0
- data/lib/generators/nuntius/tailwind_config_generator.rb +4 -0
- data/lib/generators/nuntius/templates/config/initializers/nuntius.rb +11 -0
- data/lib/nuntius/version.rb +1 -1
- data/lib/tasks/nuntius_tasks.rake +3 -1
- metadata +8 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 7180b584a94828e410a42d835438c6cf31515fc9b4dde511b094ba2b4b03490d
|
|
4
|
+
data.tar.gz: f1b864e4d6ccf58829584985c5eb2cac1135803780f542f8d604410b27ee0e69
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
4
|
-
|
|
5
|
-
}
|
|
2
|
+
span.traffic-signal-red {
|
|
3
|
+
@apply text-red-600;
|
|
4
|
+
}
|
|
6
5
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
6
|
+
span.traffic-signal-green {
|
|
7
|
+
@apply text-green-600;
|
|
8
|
+
}
|
|
10
9
|
|
|
11
|
-
|
|
12
|
-
|
|
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 <
|
|
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
|
-
|
|
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
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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
|
|
data/lib/nuntius/version.rb
CHANGED
|
@@ -7,7 +7,9 @@ namespace :nuntius do
|
|
|
7
7
|
end
|
|
8
8
|
end
|
|
9
9
|
|
|
10
|
-
|
|
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.
|
|
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-
|
|
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
|