shiplog 0.3.2 → 0.4.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/CHANGELOG.md +36 -0
- data/README.md +170 -2
- data/app/controllers/shiplog/admin/announcements_controller.rb +19 -0
- data/app/controllers/shiplog/admin/entries/announcement_notifications_controller.rb +18 -0
- data/app/controllers/shiplog/admin/entries/exports_controller.rb +45 -0
- data/app/controllers/shiplog/admin/entries/newsletter_retries_controller.rb +17 -0
- data/app/controllers/shiplog/admin/entries/newsletter_tests_controller.rb +39 -0
- data/app/controllers/shiplog/admin/entries/newsletters_controller.rb +40 -0
- data/app/controllers/shiplog/admin/entries/translations_controller.rb +1 -0
- data/app/controllers/shiplog/admin/entries_controller.rb +7 -3
- data/app/controllers/shiplog/admin/newsletter_subscribers_controller.rb +47 -0
- data/app/controllers/shiplog/changelog_entries_controller.rb +14 -2
- data/app/controllers/shiplog/changelog_feeds_controller.rb +6 -2
- data/app/controllers/shiplog/changelogs_controller.rb +11 -1
- data/app/controllers/shiplog/frontends_controller.rb +2 -1
- data/app/controllers/shiplog/newsletter/confirmations_controller.rb +27 -0
- data/app/controllers/shiplog/newsletter/preferences_controller.rb +50 -0
- data/app/controllers/shiplog/newsletter/subscriptions_controller.rb +70 -0
- data/app/controllers/shiplog/newsletter/unsubscriptions_controller.rb +36 -0
- data/app/controllers/shiplog/newsletter_controller.rb +34 -0
- data/app/controllers/shiplog/public_controller.rb +6 -0
- data/app/frontend/shiplog/admin.css +5 -0
- data/app/frontend/shiplog/once.js +31 -0
- data/app/frontend/shiplog/shiplog.css +72 -0
- data/app/helpers/shiplog/admin/entries_helper.rb +2 -0
- data/app/helpers/shiplog/newsletter_helper.rb +28 -0
- data/app/helpers/shiplog/newsletter_mailer_helper.rb +37 -0
- data/app/helpers/shiplog/permalink_helper.rb +20 -2
- data/app/helpers/shiplog/unseen_helper.rb +4 -0
- data/app/jobs/shiplog/announce_job.rb +30 -0
- data/app/jobs/shiplog/draft_announcement_job.rb +9 -0
- data/app/jobs/shiplog/purge_newsletter_job.rb +15 -0
- data/app/jobs/shiplog/send_newsletter_job.rb +64 -0
- data/app/jobs/shiplog/translate_entry_job.rb +2 -0
- data/app/mailers/shiplog/newsletter_mailer.rb +51 -0
- data/app/models/shiplog/announcements.rb +34 -0
- data/app/models/shiplog/area_detector.rb +2 -1
- data/app/models/shiplog/change_candidates.rb +4 -0
- data/app/models/shiplog/drafter.rb +4 -2
- data/app/models/shiplog/entry.rb +76 -6
- data/app/models/shiplog/markdown.rb +233 -0
- data/app/models/shiplog/newsletter/content.rb +172 -0
- data/app/models/shiplog/newsletter/csv.rb +32 -0
- data/app/models/shiplog/newsletter/recipients.rb +92 -0
- data/app/models/shiplog/newsletter/tokens.rb +77 -0
- data/app/models/shiplog/newsletter.rb +180 -0
- data/app/models/shiplog/newsletter_delivery.rb +83 -0
- data/app/models/shiplog/newsletter_subscriber.rb +158 -0
- data/app/models/shiplog/period.rb +129 -0
- data/app/models/shiplog/public_links.rb +47 -0
- data/app/models/shiplog/rewriter/announcement_prompt.rb +70 -0
- data/app/models/shiplog/rewriter.rb +23 -3
- data/app/models/shiplog/translator/prompt.rb +2 -2
- data/app/models/shiplog/translator.rb +1 -1
- data/app/views/shiplog/admin/_nav.html.erb +3 -0
- data/app/views/shiplog/admin/entries/_announcement.html.erb +52 -0
- data/app/views/shiplog/admin/entries/_badges.html.erb +1 -1
- data/app/views/shiplog/admin/entries/_newsletter.html.erb +42 -0
- data/app/views/shiplog/admin/entries/edit.html.erb +8 -2
- data/app/views/shiplog/admin/entries/index.html.erb +9 -0
- data/app/views/shiplog/admin/entries/newsletters/show.html.erb +46 -0
- data/app/views/shiplog/admin/newsletter_subscribers/index.html.erb +83 -0
- data/app/views/shiplog/changelog_entries/announcement.html.erb +16 -0
- data/app/views/shiplog/changelogs/_month.html.erb +10 -3
- data/app/views/shiplog/changelogs/show.html.erb +4 -1
- data/app/views/shiplog/newsletter/_form.html.erb +25 -0
- data/app/views/shiplog/newsletter/_page.html.erb +14 -0
- data/app/views/shiplog/newsletter/confirmations/create.html.erb +5 -0
- data/app/views/shiplog/newsletter/confirmations/show.html.erb +5 -0
- data/app/views/shiplog/newsletter/invalid.html.erb +4 -0
- data/app/views/shiplog/newsletter/preferences/destroy.html.erb +4 -0
- data/app/views/shiplog/newsletter/preferences/show.html.erb +23 -0
- data/app/views/shiplog/newsletter/rate_limited.html.erb +4 -0
- data/app/views/shiplog/newsletter/subscriptions/show.html.erb +16 -0
- data/app/views/shiplog/newsletter/unsubscriptions/create.html.erb +4 -0
- data/app/views/shiplog/newsletter/unsubscriptions/show.html.erb +5 -0
- data/app/views/shiplog/newsletter_mailer/announcement.html.erb +113 -0
- data/app/views/shiplog/newsletter_mailer/announcement.text.erb +38 -0
- data/app/views/shiplog/newsletter_mailer/confirmation.html.erb +43 -0
- data/app/views/shiplog/newsletter_mailer/confirmation.text.erb +12 -0
- data/config/admin_routes.rb +12 -0
- data/config/locales/en.yml +147 -0
- data/config/locales/es.yml +147 -0
- data/config/routes.rb +13 -0
- data/db/migrate/20261028000001_shiplog_0_4_0.rb +19 -0
- data/db/migrate/20261028000002_shiplog_0_4_0_newsletter.rb +44 -0
- data/lib/generators/shiplog/install/install_generator.rb +14 -1
- data/lib/generators/shiplog/install/templates/initializer.rb.tt +50 -0
- data/lib/shiplog/classes.rb +14 -1
- data/lib/shiplog/configuration.rb +132 -1
- data/lib/shiplog/engine.rb +2 -0
- data/lib/shiplog/previews/shiplog/newsletter_mailer_preview.rb +77 -0
- data/lib/shiplog/version.rb +1 -1
- data/lib/shiplog.rb +32 -0
- data/lib/tasks/shiplog.rake +22 -0
- metadata +56 -1
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
module Shiplog
|
|
2
|
+
module Newsletter
|
|
3
|
+
# What a newsletter says in one locale: the announcement's title, subject and body (the current translation,
|
|
4
|
+
# English when there is none), the Markdown rendered once as inline-styled HTML and as plain text, the
|
|
5
|
+
# month's feature highlights (cards with a screenshot, a sentence and a link), and the links.
|
|
6
|
+
# SendNewsletterJob builds one per locale and hands it to every message of that locale.
|
|
7
|
+
#
|
|
8
|
+
# The split rule: when there are highlights and the body has at least two "## " sections, the email shows
|
|
9
|
+
# the intro (the text before the first heading), the highlight cards in place of the body's FIRST section,
|
|
10
|
+
# then the rest of the body from the second section on ("Also improved"). Otherwise the body is shown whole.
|
|
11
|
+
# The split is made on the Markdown of the locale's text, so it works for translations whatever the headings
|
|
12
|
+
# say. Card texts come from the features' own approved (translated) entries, not from the body.
|
|
13
|
+
class Content
|
|
14
|
+
MAX_HIGHLIGHTS = 3
|
|
15
|
+
SUMMARY_LENGTH = 180
|
|
16
|
+
PREHEADER_LENGTH = 150
|
|
17
|
+
# A "## " heading at the start of a line (Markdown allows up to three leading spaces).
|
|
18
|
+
SECTION = /^ {0,3}\#{2}[ \t]/
|
|
19
|
+
|
|
20
|
+
# One highlight card: a feature's title, first sentence and permalink, and its first screenshot (absolute
|
|
21
|
+
# URL, alt text, size when the blob was analysed) or nil without one.
|
|
22
|
+
Highlight = Data.define(:title, :summary, :url, :image_url, :image_alt, :image_width, :image_height) do
|
|
23
|
+
def image? = image_url.present?
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# The inline styles of the body's tags in the HTML part (email clients ignore stylesheets; see Markdown).
|
|
27
|
+
BODY_STYLES = {
|
|
28
|
+
"h1" => "margin:28px 0 10px;font-size:20px;line-height:1.3;font-weight:700;color:#1f2328;",
|
|
29
|
+
"h2" => "margin:28px 0 10px;font-size:18px;line-height:1.3;font-weight:700;color:#1f2328;",
|
|
30
|
+
"h3" => "margin:20px 0 6px;font-size:15px;line-height:1.4;font-weight:600;color:#1f2328;",
|
|
31
|
+
"h4" => "margin:16px 0 4px;font-size:15px;line-height:1.4;font-weight:600;color:#1f2328;",
|
|
32
|
+
"h5" => "margin:16px 0 4px;font-size:15px;line-height:1.4;font-weight:600;color:#1f2328;",
|
|
33
|
+
"h6" => "margin:16px 0 4px;font-size:15px;line-height:1.4;font-weight:600;color:#1f2328;",
|
|
34
|
+
"p" => "margin:0 0 12px;font-size:16px;line-height:1.6;color:#1f2328;",
|
|
35
|
+
"ul" => "margin:0 0 12px;padding:0 0 0 20px;",
|
|
36
|
+
"ol" => "margin:0 0 12px;padding:0 0 0 20px;",
|
|
37
|
+
"li" => "margin:0 0 4px;font-size:15px;line-height:1.55;color:#1f2328;",
|
|
38
|
+
"blockquote" => "margin:0 0 12px;padding:0 0 0 12px;border-left:3px solid #d1d9e0;color:#59636e;",
|
|
39
|
+
"hr" => "border:0;border-top:1px solid #d1d9e0;margin:20px 0;",
|
|
40
|
+
"code" => "font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:14px;background-color:#f6f8fa;padding:1px 4px;border-radius:3px;",
|
|
41
|
+
"img" => "display:block;max-width:100%;height:auto;border-radius:8px;margin:0 0 12px;"
|
|
42
|
+
}.freeze
|
|
43
|
+
|
|
44
|
+
attr_reader :entry, :locale
|
|
45
|
+
|
|
46
|
+
# `highlights:` replaces the ones built from the month's features (the mailer preview, with no rows).
|
|
47
|
+
def initialize(entry, locale, highlights: nil)
|
|
48
|
+
@entry = entry
|
|
49
|
+
@locale = Newsletter.locale_for(locale)
|
|
50
|
+
@highlights = highlights
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def title = entry.display_title(locale).to_s
|
|
54
|
+
|
|
55
|
+
def subject = title
|
|
56
|
+
|
|
57
|
+
# The month the announcement covers ("October 2026"), in the locale when its I18n data has month names.
|
|
58
|
+
def month = Period.month_name(entry.released_on || Date.current, locale: Newsletter.i18n_locale(locale))
|
|
59
|
+
|
|
60
|
+
# The whole body as HTML, each tag inline-styled, links in the accent colour. Rendered once per Content.
|
|
61
|
+
def html = @html ||= render_html(markdown)
|
|
62
|
+
|
|
63
|
+
def text = @text ||= entry.to_text(locale)
|
|
64
|
+
|
|
65
|
+
# The body's first line, cut short, for the preheader (the preview line next to the subject).
|
|
66
|
+
def preheader = (intro_text.presence || text).lines.first.to_s.squish.truncate(PREHEADER_LENGTH)
|
|
67
|
+
|
|
68
|
+
# Up to MAX_HIGHLIGHTS of the month's approved features, in the changelog's order, each with a permalink
|
|
69
|
+
# (features without one are skipped). Built once per Content.
|
|
70
|
+
def highlights
|
|
71
|
+
@highlights ||= features.lazy.filter_map { |feature| highlight(feature) }.first(MAX_HIGHLIGHTS)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Whether the email shows the intro, the cards and the rest instead of the whole body (the split rule).
|
|
75
|
+
def highlights_shown? = highlights.any? && sections.size >= 2
|
|
76
|
+
|
|
77
|
+
# The text before the body's first "## " heading, as HTML; nil unless the split rule applies.
|
|
78
|
+
def intro_html
|
|
79
|
+
@intro_html ||= render_html(intro_markdown) if highlights_shown?
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# The body from its second "## " heading on, as HTML; nil unless the split rule applies. It opens a card, whose
|
|
83
|
+
# padding already spaces it, so the opening heading's top margin is dropped.
|
|
84
|
+
def rest_html
|
|
85
|
+
@rest_html ||= flush_top(render_html(rest_markdown)) if highlights_shown?
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def intro_text
|
|
89
|
+
@intro_text ||= Markdown.to_text(intro_markdown) if highlights_shown?
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def rest_text
|
|
93
|
+
@rest_text ||= Markdown.to_text(rest_markdown) if highlights_shown?
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def view_url = @view_url ||= Newsletter.announcement_url(entry, locale)
|
|
97
|
+
|
|
98
|
+
def changelog_url = @changelog_url ||= Newsletter.changelog_url
|
|
99
|
+
|
|
100
|
+
def accent = Shiplog.config.newsletter_accent || "#0969da"
|
|
101
|
+
|
|
102
|
+
def logo_url = Shiplog.config.newsletter_logo_url
|
|
103
|
+
|
|
104
|
+
def address = Shiplog.config.newsletter_address
|
|
105
|
+
|
|
106
|
+
def product_name = Newsletter.product_name
|
|
107
|
+
|
|
108
|
+
# The absolute URL of an attachment's file through Active Storage's redirect route (the host's), or nil.
|
|
109
|
+
def self.image_url(attachment)
|
|
110
|
+
path = Rails.application.routes.url_helpers.rails_blob_path(attachment.blob, only_path: true)
|
|
111
|
+
Newsletter.absolute_url(path)
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
private
|
|
115
|
+
|
|
116
|
+
def markdown = @markdown ||= entry.to_markdown(locale).gsub(/\r\n?/, "\n")
|
|
117
|
+
|
|
118
|
+
# The body cut at its "## " headings: the text before the first one, then one String per section.
|
|
119
|
+
def sections = @sections ||= markdown.split(/(?=#{SECTION.source})/).tap { |parts| parts.shift unless parts.first.to_s.match?(SECTION) }
|
|
120
|
+
|
|
121
|
+
def intro_markdown = markdown.match?(SECTION) ? markdown.split(SECTION, 2).first.to_s : markdown
|
|
122
|
+
|
|
123
|
+
def rest_markdown = sections.drop(1).join
|
|
124
|
+
|
|
125
|
+
# The HTML with the top margin of a leading heading set to 0 (its style is BODY_STYLES', so it starts "margin:").
|
|
126
|
+
def flush_top(html)
|
|
127
|
+
html.sub(/\A(\s*<h[1-6] style=")margin:\d+px/) { "#{Regexp.last_match(1)}margin:0" }.html_safe # rubocop:disable Rails/OutputSafety
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def render_html(source) = Markdown.to_html(source, styles: BODY_STYLES.merge("a" => "color:#{accent};text-decoration:underline;"))
|
|
131
|
+
|
|
132
|
+
# The month's approved features, images and this locale's translations loaded at once.
|
|
133
|
+
def features
|
|
134
|
+
period = entry.subject
|
|
135
|
+
return [] unless period.is_a?(Period)
|
|
136
|
+
|
|
137
|
+
features = period.changelog_entries.with_images_loaded.to_a.select(&:feature?)
|
|
138
|
+
Entry.preload_translations(features, locale:)
|
|
139
|
+
features
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def highlight(feature)
|
|
143
|
+
url = Newsletter.absolute_url(PublicLinks.entry_url(feature))
|
|
144
|
+
return unless url
|
|
145
|
+
|
|
146
|
+
image = Shiplog.images? ? feature.ordered_images.first : nil
|
|
147
|
+
image_url = image && screenshot_url(feature, image)
|
|
148
|
+
metadata = image_url ? image.blob.metadata : {}
|
|
149
|
+
feature_title = feature.display_title(locale).to_s
|
|
150
|
+
Highlight.new(title: feature_title, summary: first_sentence(feature.display_body(locale)), url:, image_url:,
|
|
151
|
+
image_alt: (image_url ? feature.custom_image_alt(image) || feature_title : nil),
|
|
152
|
+
image_width: metadata[:width].to_i.positive? ? metadata[:width].to_i : nil,
|
|
153
|
+
image_height: metadata[:height].to_i.positive? ? metadata[:height].to_i : nil)
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# A screenshot's absolute URL; nil, after a log line (no address in it), when the host's routes cannot give one.
|
|
157
|
+
def screenshot_url(feature, image)
|
|
158
|
+
self.class.image_url(image)
|
|
159
|
+
rescue StandardError => error
|
|
160
|
+
Rails.logger.warn("[shiplog] Newsletter: the screenshot of entry #{feature.id} is left out: #{error.class}")
|
|
161
|
+
nil
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# The first sentence of a plain-text body (punctuation kept), cut at SUMMARY_LENGTH.
|
|
165
|
+
def first_sentence(body)
|
|
166
|
+
text = body.to_s.squish
|
|
167
|
+
match = text.match(Rewriter::SENTENCE_END)
|
|
168
|
+
(match ? text[0...match.end(0)] : text).truncate(SUMMARY_LENGTH)
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
module Shiplog
|
|
2
|
+
module Newsletter
|
|
3
|
+
# The admin's CSV export of subscribers, written by hand: the csv gem is no longer a default gem and Shiplog
|
|
4
|
+
# adds no dependency for a few lines. Every cell is quoted, and a cell that a spreadsheet would read as a
|
|
5
|
+
# formula (starting with =, +, -, @, a tab or a carriage return) gets a leading apostrophe, so an address
|
|
6
|
+
# like "=HYPERLINK(...)@evil.example" cannot run anything when the file is opened.
|
|
7
|
+
module Csv
|
|
8
|
+
HEADERS = %w[email locale status source subscribed_at confirmed_at unsubscribed_at].freeze
|
|
9
|
+
FORMULA = /\A[=+\-@\t\r]/
|
|
10
|
+
|
|
11
|
+
# The CSV text of a scope of subscribers, read in batches.
|
|
12
|
+
def self.generate(scope)
|
|
13
|
+
lines = [ line(HEADERS) ]
|
|
14
|
+
scope.find_each { |subscriber| lines << line(row(subscriber)) }
|
|
15
|
+
lines.join("\n") + "\n"
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def self.row(subscriber)
|
|
19
|
+
[ subscriber.email, subscriber.locale, subscriber.status, subscriber.source,
|
|
20
|
+
*[ subscriber.created_at, subscriber.confirmed_at, subscriber.unsubscribed_at ].map { |time| time&.utc&.iso8601 } ]
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def self.line(values) = values.map { |value| cell(value) }.join(",")
|
|
24
|
+
|
|
25
|
+
def self.cell(value)
|
|
26
|
+
text = value.to_s
|
|
27
|
+
text = "'#{text}" if FORMULA.match?(text)
|
|
28
|
+
%("#{text.gsub('"', '""')}")
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
module Shiplog
|
|
2
|
+
module Newsletter
|
|
3
|
+
# The recipients of a newsletter, in batches: the host's users (config.newsletter_recipients, read in batches
|
|
4
|
+
# with find_each when the callable returns a relation) and then the confirmed subscribers. An address with an
|
|
5
|
+
# unsubscribed row (a suppression record, whatever the source of the address) is skipped, one query per
|
|
6
|
+
# batch; so is a duplicate: the first host recipient with an address wins, and a subscriber whose address a
|
|
7
|
+
# host user has is skipped. A host object without a usable email or id (or not responding to email, locale
|
|
8
|
+
# and id) is skipped with a warning that names no address, never a crash.
|
|
9
|
+
class Recipients
|
|
10
|
+
Recipient = Data.define(:key, :email, :locale, :source, :record) do
|
|
11
|
+
def host? = source == :host
|
|
12
|
+
def subscriber? = source == :subscriber
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
EMAIL = URI::MailTo::EMAIL_REGEXP
|
|
16
|
+
|
|
17
|
+
def initialize(batch_size: Shiplog.config.newsletter_batch_size)
|
|
18
|
+
@batch_size = batch_size
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Yields Arrays of at most batch_size Recipients (fewer once suppressed and duplicate addresses are dropped).
|
|
22
|
+
def each_batch(&block)
|
|
23
|
+
seen = Set.new
|
|
24
|
+
host_batches { |records| emit(records.filter_map { |record| host_recipient(record) }, seen, suppressed: true, &block) }
|
|
25
|
+
NewsletterSubscriber.confirmed.in_batches(of: @batch_size) do |subscribers|
|
|
26
|
+
emit(subscribers.map { |subscriber| subscriber_recipient(subscriber) }, seen, suppressed: false, &block)
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Yields each Recipient once, host users first.
|
|
31
|
+
def each(&block) = each_batch { |batch| batch.each(&block) }
|
|
32
|
+
|
|
33
|
+
# { "en" => 120, "es" => 30 }: how many recipients get each locale's email, after deduplication.
|
|
34
|
+
def counts_by_locale
|
|
35
|
+
counts = Hash.new(0)
|
|
36
|
+
each { |recipient| counts[recipient.locale] += 1 }
|
|
37
|
+
counts.sort_by { |locale, _| locale }.to_h
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
# Yields the candidates that are neither suppressed (checked in one query when `suppressed`; the confirmed
|
|
43
|
+
# subscribers' scope rules it out already) nor seen before.
|
|
44
|
+
def emit(candidates, seen, suppressed:)
|
|
45
|
+
skipped = suppressed ? suppressed_emails(candidates.map(&:email)) : Set.new
|
|
46
|
+
batch = candidates.select { |recipient| !skipped.include?(recipient.email) && seen.add?(recipient.email) }
|
|
47
|
+
yield batch if batch.any?
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# The addresses among `emails` that unsubscribed (NewsletterSubscriber.suppress), lowercased.
|
|
51
|
+
def suppressed_emails(emails)
|
|
52
|
+
return Set.new if emails.empty?
|
|
53
|
+
|
|
54
|
+
NewsletterSubscriber.unsubscribed.where("lower(email) IN (?)", emails).pluck(:email).map(&:downcase).to_set
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# The host's recipient objects, batch_size at a time (a relation is read with find_each).
|
|
58
|
+
def host_batches(&block)
|
|
59
|
+
callable = Shiplog.config.newsletter_recipients
|
|
60
|
+
return unless callable
|
|
61
|
+
|
|
62
|
+
records = callable.call
|
|
63
|
+
return if records.nil?
|
|
64
|
+
|
|
65
|
+
if records.respond_to?(:find_each)
|
|
66
|
+
records.find_each(batch_size: @batch_size).each_slice(@batch_size, &block)
|
|
67
|
+
else
|
|
68
|
+
records.each_slice(@batch_size, &block)
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def host_recipient(record)
|
|
73
|
+
return skip("#{record.class.name}, which does not respond to email, locale and id") unless %i[email locale id].all? { |name| record.respond_to?(name) }
|
|
74
|
+
return skip("a #{record.class.name} without an id") if record.id.blank?
|
|
75
|
+
|
|
76
|
+
email = record.email.to_s.strip.downcase
|
|
77
|
+
return if email.empty? || !EMAIL.match?(email) || email.length > NewsletterSubscriber::EMAIL_MAX_LENGTH
|
|
78
|
+
|
|
79
|
+
Recipient.new(key: "#{record.class.name}/#{record.id}", email:, locale: Newsletter.locale_for(record.locale), source: :host, record:)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def subscriber_recipient(subscriber)
|
|
83
|
+
Recipient.new(key: subscriber.recipient_key, email: subscriber.email, locale: Newsletter.locale_for(subscriber.locale), source: :subscriber, record: subscriber)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def skip(what)
|
|
87
|
+
Rails.logger.warn("[shiplog] newsletter_recipients gave #{what}: skipped")
|
|
88
|
+
nil
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
module Shiplog
|
|
2
|
+
module Newsletter
|
|
3
|
+
# The tokens in newsletter links: encrypted and signed (AES-256-GCM through ActiveSupport::MessageEncryptor,
|
|
4
|
+
# URL-safe, JSON), keyed from the host's secret_key_base, each with its own purpose so one cannot stand in for
|
|
5
|
+
# another. Nothing in a token is readable without the key (no id, no address in clear in logs or caches); a
|
|
6
|
+
# tampered or expired token decrypts to nil.
|
|
7
|
+
# confirmation(subscriber) the double opt-in link: the row's single-use confirmation_nonce, valid
|
|
8
|
+
# CONFIRMATION_VALIDITY; the nonce is cleared when the link is used or the address
|
|
9
|
+
# unsubscribes, so an old link confirms nothing
|
|
10
|
+
# unsubscribe(email) the one-click and footer unsubscribe link: the address, encrypted, so the host's
|
|
11
|
+
# users need no row in Shiplog; it does not expire (footers live long)
|
|
12
|
+
# The preferences page uses the subscriber's stored token instead (NewsletterSubscriber#token), rotated after
|
|
13
|
+
# each change made there.
|
|
14
|
+
#
|
|
15
|
+
# Rotating secret_key_base: call `Shiplog::Newsletter::Tokens.rotate(old_secret_key_base)` in an initializer
|
|
16
|
+
# while old unsubscribe links must keep working (they are decrypted with the old key too).
|
|
17
|
+
module Tokens
|
|
18
|
+
CONFIRMATION_VALIDITY = 7.days
|
|
19
|
+
SALT = "shiplog newsletter".freeze
|
|
20
|
+
KEY_LENGTH = 32
|
|
21
|
+
|
|
22
|
+
# The confirmation link's token, or nil when the row has no pending confirmation.
|
|
23
|
+
def self.confirmation(subscriber)
|
|
24
|
+
nonce = subscriber.confirmation_nonce
|
|
25
|
+
encryptor.encrypt_and_sign({ "nonce" => nonce }, purpose: :confirm, expires_in: CONFIRMATION_VALIDITY) if nonce.present?
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# The subscriber whose pending confirmation this token is, or nil (tampered, expired, used, or unsubscribed since).
|
|
29
|
+
def self.subscriber_for(token)
|
|
30
|
+
data = read(token, :confirm)
|
|
31
|
+
nonce = data["nonce"] if data.is_a?(Hash)
|
|
32
|
+
NewsletterSubscriber.find_by(confirmation_nonce: nonce) if nonce.is_a?(String) && nonce.present?
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def self.unsubscribe(email) = encryptor.encrypt_and_sign({ "email" => email.to_s.strip.downcase }, purpose: :unsubscribe)
|
|
36
|
+
|
|
37
|
+
# The address of an unsubscribe token, or nil when it does not verify.
|
|
38
|
+
def self.email_for(token)
|
|
39
|
+
data = read(token, :unsubscribe)
|
|
40
|
+
data["email"].presence if data.is_a?(Hash) && data["email"].is_a?(String)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Adds a previous secret_key_base whose tokens must still be accepted.
|
|
44
|
+
def self.rotate(secret_key_base)
|
|
45
|
+
previous_secrets << secret_key_base.to_s
|
|
46
|
+
@encryptor = nil
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def self.reset_rotation!
|
|
50
|
+
previous_secrets.clear
|
|
51
|
+
@encryptor = nil
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def self.previous_secrets = (@previous_secrets ||= [])
|
|
55
|
+
|
|
56
|
+
def self.encryptor
|
|
57
|
+
@encryptor ||= ActiveSupport::MessageEncryptor.new(Rails.application.key_generator.generate_key(SALT, KEY_LENGTH), url_safe: true, serializer: JSON).tap do |encryptor|
|
|
58
|
+
previous_secrets.each { |secret| encryptor.rotate(key_generator(secret).generate_key(SALT, KEY_LENGTH)) }
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def self.key_generator(secret)
|
|
63
|
+
ActiveSupport::KeyGenerator.new(secret, iterations: 1000, hash_digest_class: ActiveSupport::KeyGenerator.hash_digest_class)
|
|
64
|
+
end
|
|
65
|
+
private_class_method :key_generator
|
|
66
|
+
|
|
67
|
+
def self.read(token, purpose)
|
|
68
|
+
return if token.blank?
|
|
69
|
+
|
|
70
|
+
encryptor.decrypt_and_verify(token.to_s, purpose:)
|
|
71
|
+
rescue ActiveSupport::MessageEncryptor::InvalidMessage, ActiveSupport::MessageVerifier::InvalidSignature, ArgumentError
|
|
72
|
+
nil
|
|
73
|
+
end
|
|
74
|
+
private_class_method :read
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
module Shiplog
|
|
2
|
+
# The newsletter: an approved announcement emailed, in each recipient's language, to the host's users who opted
|
|
3
|
+
# in (config.newsletter_recipients, never stored) and to the visitors who subscribed through Shiplog's form
|
|
4
|
+
# (NewsletterSubscriber, double opt-in). Approving publishes the announcement; sending is a separate, explicit
|
|
5
|
+
# admin action (SendNewsletterJob, once per recipient: NewsletterDelivery). Off by default: config.newsletter.
|
|
6
|
+
# An unsubscribe is honoured whatever the source of the address: Shiplog keeps a suppression row for it (the
|
|
7
|
+
# one thing it stores about a host's user) and tells the host (config.on_newsletter_unsubscribe).
|
|
8
|
+
module Newsletter
|
|
9
|
+
def self.enabled? = Shiplog.newsletter?
|
|
10
|
+
|
|
11
|
+
# Whether the newsletter's tables exist: the unsubscribe and preferences pages keep working while they do,
|
|
12
|
+
# even with config.newsletter off (a link in an old email must still unsubscribe).
|
|
13
|
+
def self.available? = NewsletterSubscriber.table_exists?
|
|
14
|
+
|
|
15
|
+
# Why an announcement cannot be sent, as symbols the admin page explains (empty when it can): :not_approved,
|
|
16
|
+
# :translations (a configured locale has no current translation, listed in `missing_locales`), :from,
|
|
17
|
+
# :address and :base_url (config.newsletter_from, newsletter_address, and an origin for absolute links). A
|
|
18
|
+
# test send (`test: true`) needs only a sender and an origin.
|
|
19
|
+
def self.blockers(entry, test: false)
|
|
20
|
+
blockers = []
|
|
21
|
+
unless test
|
|
22
|
+
blockers << :not_approved unless entry.approved?
|
|
23
|
+
blockers << :translations if missing_locales(entry).any?
|
|
24
|
+
blockers << :address if Shiplog.config.newsletter_address.blank?
|
|
25
|
+
end
|
|
26
|
+
blockers << :from if from.blank?
|
|
27
|
+
blockers << :base_url if base_url.blank?
|
|
28
|
+
blockers
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def self.sendable?(entry) = blockers(entry).empty?
|
|
32
|
+
|
|
33
|
+
# The configured locales with no current translation of the entry.
|
|
34
|
+
def self.missing_locales(entry) = Shiplog.config.locales.reject { |locale| entry.translation(locale) }
|
|
35
|
+
|
|
36
|
+
# The sender: config.newsletter_from, else ActionMailer's default From (the confirmation email needs one too).
|
|
37
|
+
def self.from = Shiplog.config.newsletter_from.presence || ActionMailer::Base.default[:from].presence
|
|
38
|
+
|
|
39
|
+
# The origin of the links in emails: config.canonical_url, else the host and protocol of
|
|
40
|
+
# action_mailer.default_url_options (what every mailer uses), else nil (a blocker).
|
|
41
|
+
def self.base_url
|
|
42
|
+
return Shiplog.config.canonical_url if Shiplog.config.canonical_url
|
|
43
|
+
|
|
44
|
+
options = ActionMailer::Base.default_url_options || {}
|
|
45
|
+
host = options[:host].to_s.strip
|
|
46
|
+
return if host.empty?
|
|
47
|
+
|
|
48
|
+
protocol = options[:protocol].to_s.delete_suffix("://").presence || "https"
|
|
49
|
+
port = options[:port].presence
|
|
50
|
+
"#{protocol}://#{host}#{":#{port}" if port}"
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# The absolute URL of an engine route (`:newsletter_preferences_path`, ...): the base URL plus the path, which
|
|
54
|
+
# carries the engine's mount.
|
|
55
|
+
def self.url(helper, *args) = "#{base_url}#{PublicLinks.path(helper, *args)}"
|
|
56
|
+
|
|
57
|
+
# The page a newsletter's "View in browser" link opens: config.announcement_url (:external, called with the
|
|
58
|
+
# entry and the locale; a failing or unsafe result is logged and gives no link), else the announcement's
|
|
59
|
+
# own page (the default locale's URL: Shiplog cannot know how the host puts a locale in its URLs).
|
|
60
|
+
def self.announcement_url(entry, locale)
|
|
61
|
+
if Shiplog.config.announcements == :external
|
|
62
|
+
callable = Shiplog.config.announcement_url
|
|
63
|
+
return unless callable
|
|
64
|
+
|
|
65
|
+
value = callable.call(entry, locale.to_sym)
|
|
66
|
+
return value if Configuration.safe_url?(value) && value.match?(%r{\Ahttps?://}i)
|
|
67
|
+
|
|
68
|
+
Rails.logger.warn("[shiplog] config.announcement_url gave no absolute http(s) URL for the newsletter of entry #{entry.id}: #{value.inspect}")
|
|
69
|
+
nil
|
|
70
|
+
else
|
|
71
|
+
absolute(entry.public_url)
|
|
72
|
+
end
|
|
73
|
+
rescue StandardError => error
|
|
74
|
+
Rails.logger.error("[shiplog] config.announcement_url failed for the newsletter of entry #{entry.id}: #{error.class}: #{error.message}")
|
|
75
|
+
nil
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def self.changelog_url = absolute(PublicLinks.changelog_url)
|
|
79
|
+
|
|
80
|
+
# The one-click and footer unsubscribe target for any address, host user or subscriber: an encrypted token of
|
|
81
|
+
# the email, so Shiplog stores nothing about the host's users until they unsubscribe (see Tokens).
|
|
82
|
+
def self.unsubscribe_url(email) = url(:newsletter_unsubscription_path, Tokens.unsubscribe(email))
|
|
83
|
+
|
|
84
|
+
def self.preferences_url(subscriber) = url(:newsletter_preferences_path, subscriber.token)
|
|
85
|
+
|
|
86
|
+
# The confirmation link of a pending subscriber, or nil when there is no pending confirmation.
|
|
87
|
+
def self.confirmation_url(subscriber)
|
|
88
|
+
token = Tokens.confirmation(subscriber)
|
|
89
|
+
url(:newsletter_confirmation_path, token) if token
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# What a host user's footer links as "Preferences": config.newsletter_unsubscribe_url for the recipient, when
|
|
93
|
+
# it gives a safe absolute URL (a failure is logged), else nil (the footer then links Shiplog's unsubscribe page).
|
|
94
|
+
def self.host_unsubscribe_url(recipient)
|
|
95
|
+
callable = Shiplog.config.newsletter_unsubscribe_url
|
|
96
|
+
return unless callable
|
|
97
|
+
|
|
98
|
+
value = callable.call(recipient)
|
|
99
|
+
return value if Configuration.safe_url?(value) && value.match?(%r{\Ahttps?://}i)
|
|
100
|
+
|
|
101
|
+
Rails.logger.warn("[shiplog] config.newsletter_unsubscribe_url gave no absolute http(s) URL for #{recipient.class.name}/#{recipient.id}: #{value.inspect}")
|
|
102
|
+
nil
|
|
103
|
+
rescue StandardError => error
|
|
104
|
+
Rails.logger.error("[shiplog] config.newsletter_unsubscribe_url failed for #{recipient.class.name}/#{recipient.id}: #{error.class}: #{error.message}")
|
|
105
|
+
nil
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Removes an address from both lists: Shiplog's row is marked unsubscribed (created as a suppression record
|
|
109
|
+
# when there is none, so Recipients skips the address whatever the host's list says), and
|
|
110
|
+
# config.on_newsletter_unsubscribe is called with the email so the host turns its user's setting off. A hook
|
|
111
|
+
# that raises is logged; the visitor still sees the confirmation. False for an address that is not an email.
|
|
112
|
+
def self.unsubscribe(email)
|
|
113
|
+
subscriber = NewsletterSubscriber.suppress(email)
|
|
114
|
+
return false unless subscriber
|
|
115
|
+
|
|
116
|
+
notify_unsubscribe(subscriber.email)
|
|
117
|
+
true
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# The host's user opted in again: lifts the suppression. A row that was only a suppression record is deleted
|
|
121
|
+
# (nothing is left to store); a visitor who had confirmed through the form is confirmed again. False when the
|
|
122
|
+
# address was not suppressed.
|
|
123
|
+
def self.resubscribe(email)
|
|
124
|
+
subscriber = NewsletterSubscriber.find_by_email(email)
|
|
125
|
+
return false unless subscriber&.unsubscribed?
|
|
126
|
+
|
|
127
|
+
subscriber.never_confirmed? ? subscriber.destroy! : subscriber.resubscribe!
|
|
128
|
+
true
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def self.notify_unsubscribe(email)
|
|
132
|
+
Shiplog.config.on_newsletter_unsubscribe&.call(email)
|
|
133
|
+
rescue StandardError => error
|
|
134
|
+
Rails.logger.error("[shiplog] on_newsletter_unsubscribe failed for #{digest(email)}: #{error.class}: #{error.message}\n#{error.backtrace&.first(5)&.join("\n")}")
|
|
135
|
+
end
|
|
136
|
+
private_class_method :notify_unsubscribe
|
|
137
|
+
|
|
138
|
+
# A short digest of an address, for log lines (never the address itself).
|
|
139
|
+
def self.digest(email) = "email:#{Digest::SHA256.hexdigest(email.to_s.strip.downcase).first(12)}"
|
|
140
|
+
|
|
141
|
+
# What the subscribe form's per-address rate limit counts: the address lowercased, without a +tag, and
|
|
142
|
+
# without dots for Gmail (where they are ignored), digested so the cache never holds an address.
|
|
143
|
+
def self.rate_limit_key(email)
|
|
144
|
+
local, domain = email.to_s.strip.downcase.split("@", 2)
|
|
145
|
+
local = local.to_s.split("+", 2).first.to_s
|
|
146
|
+
if %w[gmail.com googlemail.com].include?(domain)
|
|
147
|
+
local = local.delete(".")
|
|
148
|
+
domain = "gmail.com"
|
|
149
|
+
end
|
|
150
|
+
Digest::SHA256.hexdigest("#{local}@#{domain}")
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# The locale a recipient's email is written in: theirs when it is the default or a configured one, else the
|
|
154
|
+
# default (as a String).
|
|
155
|
+
def self.locale_for(locale) = NewsletterSubscriber.resolve_locale(locale)
|
|
156
|
+
|
|
157
|
+
# The locale I18n can switch to for the email's chrome and the public pages: the one given when the host's
|
|
158
|
+
# I18n knows it, else the default (a configured translation locale need not be an available one).
|
|
159
|
+
def self.i18n_locale(locale)
|
|
160
|
+
candidate = locale.to_s.presence
|
|
161
|
+
candidate && I18n.locale_available?(candidate) ? candidate.to_sym : I18n.default_locale
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# The product's name for the email texts: config.product_name, else the "shiplog.newsletter.product" text.
|
|
165
|
+
def self.product_name = Shiplog.config.product_name.presence || I18n.t("shiplog.newsletter.product", default: "Product updates")
|
|
166
|
+
|
|
167
|
+
# An absolute URL for a path (or an absolute URL as is), with the origin the emails use (base_url); nil for a
|
|
168
|
+
# blank value or without a base URL.
|
|
169
|
+
def self.absolute_url(url)
|
|
170
|
+
return if url.blank?
|
|
171
|
+
return url if url.match?(%r{\Ahttps?://}i)
|
|
172
|
+
return unless base_url
|
|
173
|
+
|
|
174
|
+
"#{base_url}#{url}"
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def self.absolute(url) = absolute_url(url)
|
|
178
|
+
private_class_method :absolute
|
|
179
|
+
end
|
|
180
|
+
end
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
module Shiplog
|
|
2
|
+
# One newsletter email to one recipient: a row per announcement and recipient key ("User/42" for the host's
|
|
3
|
+
# users, "Subscriber/7" for Shiplog's), unique on both. The row is inserted BEFORE the email is sent (status
|
|
4
|
+
# "pending"), then marked "sent" or "failed" (with the error, scrubbed of any address). So a retried job, a
|
|
5
|
+
# worker that runs twice or a second click on Send never emails anyone twice: at most once per recipient. No
|
|
6
|
+
# email address is stored here; a subscriber's rows go with its row, and rows older than RETENTION are purged
|
|
7
|
+
# (PurgeNewsletterJob). The admin's "Retry failed" sends the failed rows again, and the pending rows untouched
|
|
8
|
+
# for STALLED_AFTER, which a dying worker left behind (a row is touched right before its email is sent).
|
|
9
|
+
class NewsletterDelivery < ApplicationRecord
|
|
10
|
+
STALLED_AFTER = 1.hour
|
|
11
|
+
RETENTION = 180.days
|
|
12
|
+
# How many rows a run claims at once: small, so a claimed row waits little before its send (and its touch).
|
|
13
|
+
CLAIM_SIZE = 100
|
|
14
|
+
ERROR_MAX_LENGTH = 500
|
|
15
|
+
EMAIL_LIKE = /[^\s@<>()\[\]"']+@[^\s@<>()\[\]"']+/
|
|
16
|
+
MISSING_RECIPIENT = "Shiplog::Newsletter::MissingRecipient: not among the recipients anymore".freeze
|
|
17
|
+
|
|
18
|
+
belongs_to :announcement, class_name: "Shiplog::Entry", inverse_of: :newsletter_deliveries
|
|
19
|
+
|
|
20
|
+
enum :status, %w[pending sent failed].index_by(&:itself), validate: true
|
|
21
|
+
|
|
22
|
+
scope :stalled, -> { pending.where(updated_at: ...STALLED_AFTER.ago) }
|
|
23
|
+
|
|
24
|
+
# { sent:, failed:, pending:, stalled: } for an announcement; two COUNT queries.
|
|
25
|
+
def self.progress(announcement)
|
|
26
|
+
scope = where(announcement:)
|
|
27
|
+
counts = scope.group(:status).count
|
|
28
|
+
{ sent: counts.fetch("sent", 0), failed: counts.fetch("failed", 0), pending: counts.fetch("pending", 0), stalled: scope.stalled.count }
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Inserts a pending row for each recipient that has none yet (one statement) and returns the keys of the rows
|
|
32
|
+
# inserted: the recipients this run may email. Rows that exist already (sent, failed, or pending in another
|
|
33
|
+
# run) are left alone.
|
|
34
|
+
def self.claim(announcement, recipients)
|
|
35
|
+
return [] if recipients.empty?
|
|
36
|
+
|
|
37
|
+
rows = recipients.map { |recipient| { announcement_id: announcement.id, recipient_key: recipient.key, locale: recipient.locale, status: "pending" } }
|
|
38
|
+
insert_all(rows, unique_by: [ :announcement_id, :recipient_key ], returning: [ :recipient_key ]).rows.flatten
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Puts the failed and stalled rows of an announcement back to pending in one statement and returns the keys
|
|
42
|
+
# it changed: two retries at once each get their own rows, and a run sends only what it reclaimed itself.
|
|
43
|
+
# The one place a recipient can receive the email twice: a stalled row whose email did leave before the
|
|
44
|
+
# worker died. The admin triggers it knowingly.
|
|
45
|
+
def self.reclaim(announcement)
|
|
46
|
+
sql = sanitize_sql_array([ <<~SQL.squish, { now: Time.current, id: announcement.id, stalled_before: STALLED_AFTER.ago } ])
|
|
47
|
+
UPDATE #{table_name} SET status = 'pending', error = NULL, updated_at = :now
|
|
48
|
+
WHERE announcement_id = :id AND (status = 'failed' OR (status = 'pending' AND updated_at < :stalled_before))
|
|
49
|
+
RETURNING recipient_key
|
|
50
|
+
SQL
|
|
51
|
+
with_connection { |connection| connection.exec_query(sql, "Shiplog::NewsletterDelivery Reclaim").rows.flatten }
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Touches the row right before its email is sent, so a row in flight never looks stalled.
|
|
55
|
+
def self.mark_sending(announcement, key)
|
|
56
|
+
where(announcement:, recipient_key: key).update_all(updated_at: Time.current)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def self.mark_sent(announcement, key)
|
|
60
|
+
where(announcement:, recipient_key: key).update_all(status: "sent", sent_at: Time.current, error: nil, updated_at: Time.current)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Records a failure as the exception's class and its message with anything address-like removed.
|
|
64
|
+
def self.mark_failed(announcement, key, error)
|
|
65
|
+
where(announcement:, recipient_key: key).update_all(status: "failed", error: "#{error.class}: #{scrub(error.message)}", updated_at: Time.current)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Reclaimed rows whose recipient is gone (a deleted subscriber, a user that left the host's list): failed,
|
|
69
|
+
# with the reason, rather than pending forever.
|
|
70
|
+
def self.mark_missing(announcement, keys)
|
|
71
|
+
return if keys.empty?
|
|
72
|
+
|
|
73
|
+
where(announcement:, recipient_key: keys.to_a, status: "pending").update_all(status: "failed", error: MISSING_RECIPIENT, updated_at: Time.current)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Deletes the rows older than `older_than`, whatever their status. Returns how many.
|
|
77
|
+
def self.purge(older_than: RETENTION)
|
|
78
|
+
where(created_at: ...older_than.ago).delete_all
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def self.scrub(text) = text.to_s.gsub(EMAIL_LIKE, "[email]").first(ERROR_MAX_LENGTH)
|
|
82
|
+
end
|
|
83
|
+
end
|