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.
Files changed (97) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +36 -0
  3. data/README.md +170 -2
  4. data/app/controllers/shiplog/admin/announcements_controller.rb +19 -0
  5. data/app/controllers/shiplog/admin/entries/announcement_notifications_controller.rb +18 -0
  6. data/app/controllers/shiplog/admin/entries/exports_controller.rb +45 -0
  7. data/app/controllers/shiplog/admin/entries/newsletter_retries_controller.rb +17 -0
  8. data/app/controllers/shiplog/admin/entries/newsletter_tests_controller.rb +39 -0
  9. data/app/controllers/shiplog/admin/entries/newsletters_controller.rb +40 -0
  10. data/app/controllers/shiplog/admin/entries/translations_controller.rb +1 -0
  11. data/app/controllers/shiplog/admin/entries_controller.rb +7 -3
  12. data/app/controllers/shiplog/admin/newsletter_subscribers_controller.rb +47 -0
  13. data/app/controllers/shiplog/changelog_entries_controller.rb +14 -2
  14. data/app/controllers/shiplog/changelog_feeds_controller.rb +6 -2
  15. data/app/controllers/shiplog/changelogs_controller.rb +11 -1
  16. data/app/controllers/shiplog/frontends_controller.rb +2 -1
  17. data/app/controllers/shiplog/newsletter/confirmations_controller.rb +27 -0
  18. data/app/controllers/shiplog/newsletter/preferences_controller.rb +50 -0
  19. data/app/controllers/shiplog/newsletter/subscriptions_controller.rb +70 -0
  20. data/app/controllers/shiplog/newsletter/unsubscriptions_controller.rb +36 -0
  21. data/app/controllers/shiplog/newsletter_controller.rb +34 -0
  22. data/app/controllers/shiplog/public_controller.rb +6 -0
  23. data/app/frontend/shiplog/admin.css +5 -0
  24. data/app/frontend/shiplog/once.js +31 -0
  25. data/app/frontend/shiplog/shiplog.css +72 -0
  26. data/app/helpers/shiplog/admin/entries_helper.rb +2 -0
  27. data/app/helpers/shiplog/newsletter_helper.rb +28 -0
  28. data/app/helpers/shiplog/newsletter_mailer_helper.rb +37 -0
  29. data/app/helpers/shiplog/permalink_helper.rb +20 -2
  30. data/app/helpers/shiplog/unseen_helper.rb +4 -0
  31. data/app/jobs/shiplog/announce_job.rb +30 -0
  32. data/app/jobs/shiplog/draft_announcement_job.rb +9 -0
  33. data/app/jobs/shiplog/purge_newsletter_job.rb +15 -0
  34. data/app/jobs/shiplog/send_newsletter_job.rb +64 -0
  35. data/app/jobs/shiplog/translate_entry_job.rb +2 -0
  36. data/app/mailers/shiplog/newsletter_mailer.rb +51 -0
  37. data/app/models/shiplog/announcements.rb +34 -0
  38. data/app/models/shiplog/area_detector.rb +2 -1
  39. data/app/models/shiplog/change_candidates.rb +4 -0
  40. data/app/models/shiplog/drafter.rb +4 -2
  41. data/app/models/shiplog/entry.rb +76 -6
  42. data/app/models/shiplog/markdown.rb +233 -0
  43. data/app/models/shiplog/newsletter/content.rb +172 -0
  44. data/app/models/shiplog/newsletter/csv.rb +32 -0
  45. data/app/models/shiplog/newsletter/recipients.rb +92 -0
  46. data/app/models/shiplog/newsletter/tokens.rb +77 -0
  47. data/app/models/shiplog/newsletter.rb +180 -0
  48. data/app/models/shiplog/newsletter_delivery.rb +83 -0
  49. data/app/models/shiplog/newsletter_subscriber.rb +158 -0
  50. data/app/models/shiplog/period.rb +129 -0
  51. data/app/models/shiplog/public_links.rb +47 -0
  52. data/app/models/shiplog/rewriter/announcement_prompt.rb +70 -0
  53. data/app/models/shiplog/rewriter.rb +23 -3
  54. data/app/models/shiplog/translator/prompt.rb +2 -2
  55. data/app/models/shiplog/translator.rb +1 -1
  56. data/app/views/shiplog/admin/_nav.html.erb +3 -0
  57. data/app/views/shiplog/admin/entries/_announcement.html.erb +52 -0
  58. data/app/views/shiplog/admin/entries/_badges.html.erb +1 -1
  59. data/app/views/shiplog/admin/entries/_newsletter.html.erb +42 -0
  60. data/app/views/shiplog/admin/entries/edit.html.erb +8 -2
  61. data/app/views/shiplog/admin/entries/index.html.erb +9 -0
  62. data/app/views/shiplog/admin/entries/newsletters/show.html.erb +46 -0
  63. data/app/views/shiplog/admin/newsletter_subscribers/index.html.erb +83 -0
  64. data/app/views/shiplog/changelog_entries/announcement.html.erb +16 -0
  65. data/app/views/shiplog/changelogs/_month.html.erb +10 -3
  66. data/app/views/shiplog/changelogs/show.html.erb +4 -1
  67. data/app/views/shiplog/newsletter/_form.html.erb +25 -0
  68. data/app/views/shiplog/newsletter/_page.html.erb +14 -0
  69. data/app/views/shiplog/newsletter/confirmations/create.html.erb +5 -0
  70. data/app/views/shiplog/newsletter/confirmations/show.html.erb +5 -0
  71. data/app/views/shiplog/newsletter/invalid.html.erb +4 -0
  72. data/app/views/shiplog/newsletter/preferences/destroy.html.erb +4 -0
  73. data/app/views/shiplog/newsletter/preferences/show.html.erb +23 -0
  74. data/app/views/shiplog/newsletter/rate_limited.html.erb +4 -0
  75. data/app/views/shiplog/newsletter/subscriptions/show.html.erb +16 -0
  76. data/app/views/shiplog/newsletter/unsubscriptions/create.html.erb +4 -0
  77. data/app/views/shiplog/newsletter/unsubscriptions/show.html.erb +5 -0
  78. data/app/views/shiplog/newsletter_mailer/announcement.html.erb +113 -0
  79. data/app/views/shiplog/newsletter_mailer/announcement.text.erb +38 -0
  80. data/app/views/shiplog/newsletter_mailer/confirmation.html.erb +43 -0
  81. data/app/views/shiplog/newsletter_mailer/confirmation.text.erb +12 -0
  82. data/config/admin_routes.rb +12 -0
  83. data/config/locales/en.yml +147 -0
  84. data/config/locales/es.yml +147 -0
  85. data/config/routes.rb +13 -0
  86. data/db/migrate/20261028000001_shiplog_0_4_0.rb +19 -0
  87. data/db/migrate/20261028000002_shiplog_0_4_0_newsletter.rb +44 -0
  88. data/lib/generators/shiplog/install/install_generator.rb +14 -1
  89. data/lib/generators/shiplog/install/templates/initializer.rb.tt +50 -0
  90. data/lib/shiplog/classes.rb +14 -1
  91. data/lib/shiplog/configuration.rb +132 -1
  92. data/lib/shiplog/engine.rb +2 -0
  93. data/lib/shiplog/previews/shiplog/newsletter_mailer_preview.rb +77 -0
  94. data/lib/shiplog/version.rb +1 -1
  95. data/lib/shiplog.rb +32 -0
  96. data/lib/tasks/shiplog.rake +22 -0
  97. metadata +56 -1
@@ -8,6 +8,7 @@ module Shiplog
8
8
  :roadmap_path
9
9
 
10
10
  ADMIN_CHROMES = %i[standalone hosted].freeze
11
+ ANNOUNCEMENT_MODES = [ :page, :external, false ].freeze
11
12
  # Segments of only dots ("." and "..") are path traversal, not names.
12
13
  REPOSITORY_NAME = %r{\A(?!\.+/)[\w.-]+/(?!\.+\z)[\w.-]+\z}
13
14
  REPOSITORY_OPTION_KEYS = %i[name changelog gem].freeze
@@ -59,6 +60,44 @@ module Shiplog
59
60
  attr_reader :images, :max_images_per_entry
60
61
  LOCALE_FORMAT = /\A[A-Za-z][A-Za-z0-9_-]*\z/
61
62
 
63
+ # Monthly announcements (see Shiplog::Announcements). `announcements` is where they are published:
64
+ # :page (default) Shiplog renders /changelog/2026-10, links it from the changelog's month heading and
65
+ # puts it in the Atom feed;
66
+ # :external no Shiplog page: the month heading links to `announcement_url`, and the host publishes the text
67
+ # itself (on_announcement_approved);
68
+ # false no announcements at all: nothing is drafted, rendered or announced.
69
+ attr_reader :announcements
70
+ # Where an announcement is published when `announcements` is :external: a callable
71
+ # `->(announcement, locale) { url }` evaluated in the view (instance_exec, so `main_app` route helpers work).
72
+ # A nil or unsafe result renders no link. Not part of the ETag: bump cache_version when its output changes.
73
+ attr_reader :announcement_url
74
+ # Called with the announcement (a Shiplog::Entry) once it is approved and every configured locale has a current
75
+ # translation, from Shiplog::AnnounceJob. Once per approval: an announcement regenerated and approved again
76
+ # is announced again. An `announcement_approved.shiplog` ActiveSupport notification is sent at the same time.
77
+ attr_reader :on_announcement_approved
78
+
79
+ # The newsletter (see Shiplog::Newsletter): the approved announcement emailed to the host's users who opted in
80
+ # and to the visitors who subscribed through Shiplog's form. Off by default, and nothing without announcements.
81
+ attr_reader :newsletter
82
+ # A callable returning the host's recipients, each responding to #email, #locale and #id (an Active Record
83
+ # relation is read in batches with find_each). Shiplog never stores them.
84
+ attr_reader :newsletter_recipients
85
+ # `->(recipient) { url }`: the host's own unsubscribe or settings page, linked in the footer for its users. The
86
+ # one-click unsubscribe (List-Unsubscribe-Post) always goes through Shiplog's endpoint, which calls
87
+ # on_newsletter_unsubscribe. Without it the footer links Shiplog's unsubscribe page.
88
+ attr_reader :newsletter_unsubscribe_url
89
+ # `->(email) { ... }`, called on every unsubscribe (one-click, the footer link or the preferences page), so the
90
+ # host turns its own user's setting off. Errors are logged, never shown to the visitor.
91
+ attr_reader :on_newsletter_unsubscribe
92
+ # The sender, e.g. "Acme <updates@acme.com>" (also used by the confirmation email); the postal address the
93
+ # footer carries (CAN-SPAM requires one); the logo (an http(s) URL) and the accent colour ("#rrggbb") of the
94
+ # email; extra ActionMailer options for every newsletter message, e.g. { message_stream: "broadcast" } for a
95
+ # Postmark broadcast stream; how many recipients a job claims at a time (default 500).
96
+ attr_reader :newsletter_from, :newsletter_address, :newsletter_logo_url, :newsletter_accent,
97
+ :newsletter_mailer_options, :newsletter_batch_size
98
+ ACCENT_FORMAT = /\A#(?:\h{3}|\h{6})\z/
99
+ MAX_NEWSLETTER_BATCH_SIZE = 10_000
100
+
62
101
  EXTRA_PAGE_KEYS = %i[label path].freeze
63
102
 
64
103
  # A path (not "//host", "/\host" or "/ x", which browsers read as other targets), an http(s) URL or mailto:.
@@ -78,7 +117,8 @@ module Shiplog
78
117
  stylesheet, roadmap_shipped_count, cache_version, product_name, canonical_url,
79
118
  extra_pages.map { |page| [ stable.(page[:label]), stable.(page[:path]) ] },
80
119
  repository_labels.sort_by { |name, _| name.to_s }.map { |name, label| [ name, stable.(label) ] },
81
- stable.(feedback_path), changelog_search, images, max_images_per_entry, locales, structured_data, stable.(og_image)
120
+ stable.(feedback_path), changelog_search, images, max_images_per_entry, locales, structured_data, stable.(og_image),
121
+ announcements.to_s, stable.(announcement_url), newsletter
82
122
  ]
83
123
  Digest::SHA256.hexdigest(JSON.generate(data))
84
124
  end
@@ -169,11 +209,90 @@ module Shiplog
169
209
  @og_image = value
170
210
  end
171
211
 
212
+ def announcements=(value)
213
+ mode = value.is_a?(String) ? value.to_sym : value
214
+ unless ANNOUNCEMENT_MODES.include?(mode)
215
+ raise ArgumentError, "announcements must be :page, :external or false, got #{value.inspect}"
216
+ end
217
+ @announcements = mode
218
+ end
219
+
220
+ def announcement_url=(value)
221
+ raise ArgumentError, "announcement_url must be nil or a callable like ->(announcement, locale) { url }, got #{value.inspect}" unless value.nil? || value.respond_to?(:call)
222
+ @announcement_url = value
223
+ end
224
+
225
+ def on_announcement_approved=(value)
226
+ raise ArgumentError, "on_announcement_approved must be nil or a callable like ->(announcement) { ... }, got #{value.inspect}" unless value.nil? || value.respond_to?(:call)
227
+ @on_announcement_approved = value
228
+ end
229
+
172
230
  def on_changelog_seen=(value)
173
231
  raise ArgumentError, "on_changelog_seen must be nil or a callable, got #{value.inspect}" unless value.nil? || value.respond_to?(:call)
174
232
  @on_changelog_seen = value
175
233
  end
176
234
 
235
+ def newsletter=(value)
236
+ raise ArgumentError, "newsletter must be true or false, got #{value.inspect}" unless [ true, false ].include?(value)
237
+ @newsletter = value
238
+ end
239
+
240
+ def newsletter_recipients=(value)
241
+ raise ArgumentError, "newsletter_recipients must be nil or a callable like -> { User.where(newsletter: true) }, got #{value.inspect}" unless value.nil? || value.respond_to?(:call)
242
+ @newsletter_recipients = value
243
+ end
244
+
245
+ def newsletter_unsubscribe_url=(value)
246
+ raise ArgumentError, "newsletter_unsubscribe_url must be nil or a callable like ->(recipient) { url }, got #{value.inspect}" unless value.nil? || value.respond_to?(:call)
247
+ @newsletter_unsubscribe_url = value
248
+ end
249
+
250
+ def on_newsletter_unsubscribe=(value)
251
+ raise ArgumentError, "on_newsletter_unsubscribe must be nil or a callable like ->(email) { ... }, got #{value.inspect}" unless value.nil? || value.respond_to?(:call)
252
+ @on_newsletter_unsubscribe = value
253
+ end
254
+
255
+ def newsletter_from=(value)
256
+ @newsletter_from = optional_string!(:newsletter_from, value, 'like "Acme <updates@acme.com>"')
257
+ end
258
+
259
+ def newsletter_address=(value)
260
+ @newsletter_address = optional_string!(:newsletter_address, value, "(the sender's postal address)")
261
+ end
262
+
263
+ def newsletter_logo_url=(value)
264
+ url = optional_string!(:newsletter_logo_url, value, "with an http(s) URL")
265
+ raise ArgumentError, "newsletter_logo_url must be an http(s) URL, got #{value.inspect}" if url && !url.match?(%r{\Ahttps?://\S+\z}i)
266
+ @newsletter_logo_url = url
267
+ end
268
+
269
+ def newsletter_accent=(value)
270
+ colour = optional_string!(:newsletter_accent, value, 'like "#0969da"')
271
+ raise ArgumentError, "newsletter_accent must be a hex colour like \"#0969da\", got #{value.inspect}" if colour && !ACCENT_FORMAT.match?(colour)
272
+ @newsletter_accent = colour&.downcase
273
+ end
274
+
275
+ # Merged into every newsletter message's `mail(...)` call: ActionMailer options (delivery_method,
276
+ # delivery_method_options) or headers your delivery gem reads (Postmark's message_stream). Keys that would
277
+ # change who receives the message or what it says are refused.
278
+ RESERVED_MAILER_OPTIONS = %i[to cc bcc from reply_to sender return_path subject body template_name template_path parts_order content_type charset mime_version date].freeze
279
+
280
+ def newsletter_mailer_options=(value)
281
+ options = value.nil? ? {} : value
282
+ raise ArgumentError, "newsletter_mailer_options must be a Hash like { message_stream: \"broadcast\" }, got #{value.inspect}" unless options.is_a?(Hash)
283
+ options = options.transform_keys { |key| key.to_s.to_sym }
284
+ reserved = options.keys & RESERVED_MAILER_OPTIONS
285
+ raise ArgumentError, "newsletter_mailer_options cannot set #{reserved.map(&:inspect).join(", ")}: the recipient, sender and content of a newsletter are Shiplog's" if reserved.any?
286
+ @newsletter_mailer_options = options.freeze
287
+ end
288
+
289
+ def newsletter_batch_size=(value)
290
+ unless value.is_a?(Integer) && value.between?(1, MAX_NEWSLETTER_BATCH_SIZE)
291
+ raise ArgumentError, "newsletter_batch_size must be an Integer between 1 and #{MAX_NEWSLETTER_BATCH_SIZE}, got #{value.inspect}"
292
+ end
293
+ @newsletter_batch_size = value
294
+ end
295
+
177
296
  # :standalone (default) wraps the admin in Shiplog's own page chrome; :hosted renders it inside the
178
297
  # layout of config.admin_layout (compact sub-nav, no page chrome of its own).
179
298
  def admin_chrome=(value)
@@ -245,6 +364,10 @@ module Shiplog
245
364
  @images = true
246
365
  @max_images_per_entry = 4
247
366
  @locales = [].freeze
367
+ @announcements = :page
368
+ @newsletter = false
369
+ @newsletter_mailer_options = {}.freeze
370
+ @newsletter_batch_size = 500
248
371
  end
249
372
 
250
373
  def resolved_github_token
@@ -283,6 +406,14 @@ module Shiplog
283
406
 
284
407
  private
285
408
 
409
+ # Nil (also for a blank String) or a stripped non-empty String; anything else raises.
410
+ def optional_string!(setting, value, hint)
411
+ return if value.nil? || (value.is_a?(String) && value.strip.empty?)
412
+ raise ArgumentError, "#{setting} must be nil or a String #{hint}, got #{value.inspect}" unless value.is_a?(String)
413
+
414
+ value.strip
415
+ end
416
+
286
417
  def normalize_canonical_url(value)
287
418
  return if value.nil? || (value.is_a?(String) && value.strip.empty?)
288
419
  raise ArgumentError, "canonical_url must be a String like \"https://example.com\", got #{value.inspect}" unless value.is_a?(String)
@@ -9,5 +9,7 @@ module Shiplog
9
9
 
10
10
  # Once per boot, after the host's initializers have set config.images: say why screenshots are off.
11
11
  config.after_initialize { Shiplog.log_images_status }
12
+ # And why the newsletter, when on, cannot be sent yet.
13
+ config.after_initialize { Shiplog.log_newsletter_status }
12
14
  end
13
15
  end
@@ -0,0 +1,77 @@
1
+ module Shiplog
2
+ # Previews of the newsletter's emails, for hosts iterating on the design locally. Add the directory to your
3
+ # preview paths (config/environments/development.rb):
4
+ #
5
+ # config.action_mailer.preview_paths << Shiplog::Engine.root.join("lib/shiplog/previews").to_s
6
+ #
7
+ # then open /rails/mailers/shiplog/newsletter_mailer. The announcement is the newest approved one (with the
8
+ # month's real feature highlights), or a sample with sample highlights when there is none (no database rows
9
+ # needed); ?locale=es previews a configured locale.
10
+ class NewsletterMailerPreview < ActionMailer::Preview
11
+ SAMPLE_BODY = <<~MARKDOWN.freeze
12
+ This month we made imports faster and added a dark theme. Here is what shipped.
13
+
14
+ ## Highlights
15
+
16
+ ### Dark mode
17
+
18
+ Switch to a dark theme from your settings. Easier on the eyes at night.
19
+
20
+ ### Faster imports
21
+
22
+ Spreadsheets with thousands of rows now import in seconds.
23
+
24
+ ## Also improved
25
+
26
+ ### Billing
27
+
28
+ - Invoices show the tax breakdown.
29
+ - Refunds appear on the next statement.
30
+
31
+ ### Exports
32
+
33
+ - CSV files keep leading zeros.
34
+ MARKDOWN
35
+
36
+ SAMPLE_HIGHLIGHTS = [
37
+ { title: "Dark mode", summary: "Switch to a dark theme from your settings, easier on the eyes at night.", slug: "dark-mode",
38
+ image_url: "https://placehold.co/1200x700/png?text=Dark+mode", image_width: 1200, image_height: 700 },
39
+ { title: "Faster imports", summary: "Spreadsheets with thousands of rows now import in seconds.", slug: "faster-imports",
40
+ image_url: "https://placehold.co/1200x700/png?text=Faster+imports", image_width: 1200, image_height: 700 },
41
+ { title: "Shared views", summary: "Save a filtered view and share it with your team.", slug: "shared-views", image_url: nil }
42
+ ].freeze
43
+
44
+ def announcement
45
+ entry = Entry.published_announcements.order(released_on: :desc).first
46
+ locale = (params["locale"] || params[:locale]).presence || I18n.default_locale
47
+ email = "preview@example.com"
48
+ content = entry ? nil : Newsletter::Content.new(sample_announcement, locale, highlights: sample_highlights)
49
+ NewsletterMailer.announcement(entry || content.entry, email, locale:, content:, unsubscribe_url: Newsletter.unsubscribe_url(email),
50
+ preferences_url: Newsletter.preferences_url(sample_subscriber))
51
+ end
52
+
53
+ def confirmation
54
+ NewsletterMailer.confirmation(sample_subscriber)
55
+ end
56
+
57
+ private
58
+
59
+ def sample_announcement
60
+ month = Date.current.prev_month.beginning_of_month
61
+ Entry.new(kind: "announcement", status: "approved", title: Period.month_name(month), body: SAMPLE_BODY,
62
+ released_on: month.end_of_month, slug: month.strftime("%Y-%m"), subject: Period.new(starts_on: month, ends_on: month.end_of_month))
63
+ end
64
+
65
+ # Three cards, two with a (placeholder) screenshot, linking to sample permalinks under the email's origin.
66
+ def sample_highlights
67
+ SAMPLE_HIGHLIGHTS.map do |sample|
68
+ Newsletter::Content::Highlight.new(title: sample[:title], summary: sample[:summary], url: Newsletter.absolute_url(PublicLinks.path(:changelog_entry_path, sample[:slug])),
69
+ image_url: sample[:image_url], image_alt: sample[:title], image_width: sample[:image_width], image_height: sample[:image_height])
70
+ end
71
+ end
72
+
73
+ def sample_subscriber
74
+ NewsletterSubscriber.new(email: "preview@example.com", locale: I18n.default_locale.to_s, token: "preview-token", confirmation_nonce: "preview-nonce")
75
+ end
76
+ end
77
+ end
@@ -1,3 +1,3 @@
1
1
  module Shiplog
2
- VERSION = "0.3.2"
2
+ VERSION = "0.4.0"
3
3
  end
data/lib/shiplog.rb CHANGED
@@ -18,6 +18,38 @@ module Shiplog
18
18
  # at 99. Counted on approved_at (when the entry was approved), not released_on (the list's order). The host supplies the timestamp (Shiplog doesn't know its users): see config.on_changelog_seen.
19
19
  def unseen_count(since: nil) = Entry.unseen_count(since:)
20
20
 
21
+ # The number of approved monthly announcements approved after `since` (all of them while it is nil), capped
22
+ # at 99: the rare "what's new" signal, where unseen_count counts every daily entry. 0 while announcements are off.
23
+ def unseen_announcements_count(since: nil) = announcements? ? Entry.unseen_announcements_count(since:) : 0
24
+
25
+ # Whether monthly announcements are on (config.announcements is :page or :external).
26
+ def announcements? = config.announcements != false
27
+
28
+ # Whether Shiplog renders announcement pages (config.announcements is :page).
29
+ def announcement_pages? = config.announcements == :page
30
+
31
+ # Whether the newsletter is on: config.newsletter, and announcements (there is nothing else to send).
32
+ def newsletter? = config.newsletter && announcements?
33
+
34
+ # Logged once at boot (see Shiplog::Engine), while the newsletter is on: what keeps it from sending, a
35
+ # recipients callable without the unsubscribe hook (unsubscribes would not reach the host's list), and a
36
+ # cache that counts nothing (the subscribe form's rate limits are then off).
37
+ def log_newsletter_status
38
+ return unless newsletter?
39
+
40
+ missing = []
41
+ missing << "newsletter_from" if Newsletter.from.blank?
42
+ missing << "newsletter_address" if config.newsletter_address.blank?
43
+ missing << "canonical_url (or action_mailer.default_url_options)" if Newsletter.base_url.blank?
44
+ Rails.logger&.warn("[shiplog] config.newsletter is on, but the newsletter cannot be sent until these are set: #{missing.join(", ")}.") if missing.any?
45
+ if config.newsletter_recipients && config.on_newsletter_unsubscribe.nil?
46
+ Rails.logger&.warn("[shiplog] config.newsletter_recipients is set but config.on_newsletter_unsubscribe is not: Shiplog suppresses an address that unsubscribes, but your own list will not know.")
47
+ end
48
+ if Rails.cache.is_a?(ActiveSupport::Cache::NullStore)
49
+ Rails.logger&.warn("[shiplog] Rails.cache is a null store, so the newsletter's subscribe form is not rate limited. Configure a cache store.")
50
+ end
51
+ end
52
+
21
53
  ACTIVE_STORAGE_TABLES = %w[active_storage_attachments active_storage_blobs].freeze
22
54
 
23
55
  # Whether the host loaded Active Storage (its engine and the model macros). The declaration on Entry needs
@@ -8,6 +8,28 @@ unless Rake::Task.task_defined?("shiplog:backfill_slugs")
8
8
  puts "Shiplog: gave #{count} #{count == 1 ? "entry" : "entries"} a slug."
9
9
  end
10
10
 
11
+ desc "Draft the announcement of a month (MONTH=2026-10; the previous month by default) and queue its rewrite"
12
+ task draft_announcement: :environment do
13
+ month = ENV["MONTH"].present? ? Date.strptime(ENV["MONTH"], "%Y-%m") : Date.current.prev_month
14
+ entry = Shiplog::Announcements.draft(month:)
15
+ label = month.strftime("%Y-%m")
16
+ if entry
17
+ puts "Shiplog: drafted the #{label} announcement (entry #{entry.id})."
18
+ elsif !Shiplog.announcements?
19
+ puts "Shiplog: config.announcements is false, nothing drafted."
20
+ else
21
+ puts "Shiplog: nothing drafted for #{label}: it has an announcement already, or no approved feature or change."
22
+ end
23
+ rescue Date::Error
24
+ abort "Shiplog: MONTH must look like 2026-10, got #{ENV["MONTH"].inspect}."
25
+ end
26
+
27
+ desc "Delete the newsletter subscribers who never confirmed within 30 days and the delivery rows older than 180 days (what Shiplog::PurgeNewsletterJob does)"
28
+ task purge_newsletter: :environment do
29
+ counts = Shiplog::PurgeNewsletterJob.perform_now
30
+ puts "Shiplog: purged #{counts[:subscribers]} unconfirmed newsletter #{counts[:subscribers] == 1 ? "subscriber" : "subscribers"} and #{counts[:deliveries]} old delivery #{counts[:deliveries] == 1 ? "row" : "rows"}."
31
+ end
32
+
11
33
  desc "Translate approved entries and area names that have no current translation into config.locales (entries approved before 0.3.0 get none). BATCH_SIZE=100"
12
34
  task translate_approved: :environment do
13
35
  if Shiplog.config.locales.empty?
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: shiplog
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.2
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Trackberry
@@ -39,6 +39,7 @@ files:
39
39
  - CHANGELOG.md
40
40
  - MIT-LICENSE
41
41
  - README.md
42
+ - app/controllers/shiplog/admin/announcements_controller.rb
42
43
  - app/controllers/shiplog/admin/application_controller.rb
43
44
  - app/controllers/shiplog/admin/area_detections_controller.rb
44
45
  - app/controllers/shiplog/admin/area_translations_controller.rb
@@ -46,27 +47,39 @@ files:
46
47
  - app/controllers/shiplog/admin/areas/moves_controller.rb
47
48
  - app/controllers/shiplog/admin/areas_controller.rb
48
49
  - app/controllers/shiplog/admin/boards_controller.rb
50
+ - app/controllers/shiplog/admin/entries/announcement_notifications_controller.rb
49
51
  - app/controllers/shiplog/admin/entries/approvals_controller.rb
50
52
  - app/controllers/shiplog/admin/entries/discards_controller.rb
53
+ - app/controllers/shiplog/admin/entries/exports_controller.rb
51
54
  - app/controllers/shiplog/admin/entries/images/moves_controller.rb
52
55
  - app/controllers/shiplog/admin/entries/images_controller.rb
56
+ - app/controllers/shiplog/admin/entries/newsletter_retries_controller.rb
57
+ - app/controllers/shiplog/admin/entries/newsletter_tests_controller.rb
58
+ - app/controllers/shiplog/admin/entries/newsletters_controller.rb
53
59
  - app/controllers/shiplog/admin/entries/rewrites_controller.rb
54
60
  - app/controllers/shiplog/admin/entries/translations/retranslations_controller.rb
55
61
  - app/controllers/shiplog/admin/entries/translations_controller.rb
56
62
  - app/controllers/shiplog/admin/entries_controller.rb
57
63
  - app/controllers/shiplog/admin/inbox_filters.rb
64
+ - app/controllers/shiplog/admin/newsletter_subscribers_controller.rb
58
65
  - app/controllers/shiplog/admin/syncs_controller.rb
59
66
  - app/controllers/shiplog/application_controller.rb
60
67
  - app/controllers/shiplog/changelog_entries_controller.rb
61
68
  - app/controllers/shiplog/changelog_feeds_controller.rb
62
69
  - app/controllers/shiplog/changelogs_controller.rb
63
70
  - app/controllers/shiplog/frontends_controller.rb
71
+ - app/controllers/shiplog/newsletter/confirmations_controller.rb
72
+ - app/controllers/shiplog/newsletter/preferences_controller.rb
73
+ - app/controllers/shiplog/newsletter/subscriptions_controller.rb
74
+ - app/controllers/shiplog/newsletter/unsubscriptions_controller.rb
75
+ - app/controllers/shiplog/newsletter_controller.rb
64
76
  - app/controllers/shiplog/public_controller.rb
65
77
  - app/controllers/shiplog/roadmaps_controller.rb
66
78
  - app/controllers/shiplog/webhooks_controller.rb
67
79
  - app/frontend/shiplog/admin.css
68
80
  - app/frontend/shiplog/board.js
69
81
  - app/frontend/shiplog/inbox.js
82
+ - app/frontend/shiplog/once.js
70
83
  - app/frontend/shiplog/shiplog.css
71
84
  - app/helpers/shiplog/admin/entries_helper.rb
72
85
  - app/helpers/shiplog/application_helper.rb
@@ -74,20 +87,28 @@ files:
74
87
  - app/helpers/shiplog/frontend_helper.rb
75
88
  - app/helpers/shiplog/head_helper.rb
76
89
  - app/helpers/shiplog/host_routes_helper.rb
90
+ - app/helpers/shiplog/newsletter_helper.rb
91
+ - app/helpers/shiplog/newsletter_mailer_helper.rb
77
92
  - app/helpers/shiplog/permalink_helper.rb
78
93
  - app/helpers/shiplog/roadmap_helper.rb
79
94
  - app/helpers/shiplog/structured_data_helper.rb
80
95
  - app/helpers/shiplog/styling_helper.rb
81
96
  - app/helpers/shiplog/switcher_helper.rb
82
97
  - app/helpers/shiplog/unseen_helper.rb
98
+ - app/jobs/shiplog/announce_job.rb
83
99
  - app/jobs/shiplog/application_job.rb
84
100
  - app/jobs/shiplog/detect_areas_job.rb
101
+ - app/jobs/shiplog/draft_announcement_job.rb
102
+ - app/jobs/shiplog/purge_newsletter_job.rb
85
103
  - app/jobs/shiplog/refresh_job.rb
86
104
  - app/jobs/shiplog/rewrite_entry_job.rb
87
105
  - app/jobs/shiplog/scan_changes_job.rb
106
+ - app/jobs/shiplog/send_newsletter_job.rb
88
107
  - app/jobs/shiplog/sync_job.rb
89
108
  - app/jobs/shiplog/translate_area_job.rb
90
109
  - app/jobs/shiplog/translate_entry_job.rb
110
+ - app/mailers/shiplog/newsletter_mailer.rb
111
+ - app/models/shiplog/announcements.rb
91
112
  - app/models/shiplog/application_record.rb
92
113
  - app/models/shiplog/area.rb
93
114
  - app/models/shiplog/area_detector.rb
@@ -106,10 +127,21 @@ files:
106
127
  - app/models/shiplog/issue.rb
107
128
  - app/models/shiplog/issue_form.rb
108
129
  - app/models/shiplog/issue_link.rb
130
+ - app/models/shiplog/markdown.rb
131
+ - app/models/shiplog/newsletter.rb
132
+ - app/models/shiplog/newsletter/content.rb
133
+ - app/models/shiplog/newsletter/csv.rb
134
+ - app/models/shiplog/newsletter/recipients.rb
135
+ - app/models/shiplog/newsletter/tokens.rb
136
+ - app/models/shiplog/newsletter_delivery.rb
137
+ - app/models/shiplog/newsletter_subscriber.rb
138
+ - app/models/shiplog/period.rb
139
+ - app/models/shiplog/public_links.rb
109
140
  - app/models/shiplog/pull_request.rb
110
141
  - app/models/shiplog/pull_request_upgrade.rb
111
142
  - app/models/shiplog/repository.rb
112
143
  - app/models/shiplog/rewriter.rb
144
+ - app/models/shiplog/rewriter/announcement_prompt.rb
113
145
  - app/models/shiplog/rewriter/prompt.rb
114
146
  - app/models/shiplog/roadmap.rb
115
147
  - app/models/shiplog/roadmap/column_resolver.rb
@@ -139,13 +171,18 @@ files:
139
171
  - app/views/shiplog/admin/areas/index.html.erb
140
172
  - app/views/shiplog/admin/boards/_card.html.erb
141
173
  - app/views/shiplog/admin/boards/show.html.erb
174
+ - app/views/shiplog/admin/entries/_announcement.html.erb
142
175
  - app/views/shiplog/admin/entries/_badges.html.erb
143
176
  - app/views/shiplog/admin/entries/_entry_row.html.erb
144
177
  - app/views/shiplog/admin/entries/_images.html.erb
178
+ - app/views/shiplog/admin/entries/_newsletter.html.erb
145
179
  - app/views/shiplog/admin/entries/_translations.html.erb
146
180
  - app/views/shiplog/admin/entries/edit.html.erb
147
181
  - app/views/shiplog/admin/entries/index.html.erb
182
+ - app/views/shiplog/admin/entries/newsletters/show.html.erb
183
+ - app/views/shiplog/admin/newsletter_subscribers/index.html.erb
148
184
  - app/views/shiplog/admin/syncs/show.html.erb
185
+ - app/views/shiplog/changelog_entries/announcement.html.erb
149
186
  - app/views/shiplog/changelog_entries/show.html.erb
150
187
  - app/views/shiplog/changelog_feeds/show.atom.builder
151
188
  - app/views/shiplog/changelogs/_areas.html.erb
@@ -158,6 +195,21 @@ files:
158
195
  - app/views/shiplog/head/_meta.html.erb
159
196
  - app/views/shiplog/head/_open_graph.html.erb
160
197
  - app/views/shiplog/head/_structured_data.html.erb
198
+ - app/views/shiplog/newsletter/_form.html.erb
199
+ - app/views/shiplog/newsletter/_page.html.erb
200
+ - app/views/shiplog/newsletter/confirmations/create.html.erb
201
+ - app/views/shiplog/newsletter/confirmations/show.html.erb
202
+ - app/views/shiplog/newsletter/invalid.html.erb
203
+ - app/views/shiplog/newsletter/preferences/destroy.html.erb
204
+ - app/views/shiplog/newsletter/preferences/show.html.erb
205
+ - app/views/shiplog/newsletter/rate_limited.html.erb
206
+ - app/views/shiplog/newsletter/subscriptions/show.html.erb
207
+ - app/views/shiplog/newsletter/unsubscriptions/create.html.erb
208
+ - app/views/shiplog/newsletter/unsubscriptions/show.html.erb
209
+ - app/views/shiplog/newsletter_mailer/announcement.html.erb
210
+ - app/views/shiplog/newsletter_mailer/announcement.text.erb
211
+ - app/views/shiplog/newsletter_mailer/confirmation.html.erb
212
+ - app/views/shiplog/newsletter_mailer/confirmation.text.erb
161
213
  - app/views/shiplog/roadmaps/_card.html.erb
162
214
  - app/views/shiplog/roadmaps/_column.html.erb
163
215
  - app/views/shiplog/roadmaps/_shipped.html.erb
@@ -169,6 +221,8 @@ files:
169
221
  - db/migrate/20261026000001_create_shiplog_tables.rb
170
222
  - db/migrate/20261027000001_create_shiplog_pull_request_upgrades.rb
171
223
  - db/migrate/20261027000002_shiplog_0_3_0.rb
224
+ - db/migrate/20261028000001_shiplog_0_4_0.rb
225
+ - db/migrate/20261028000002_shiplog_0_4_0_newsletter.rb
172
226
  - lib/generators/shiplog/install/install_generator.rb
173
227
  - lib/generators/shiplog/install/templates/initializer.rb.tt
174
228
  - lib/generators/shiplog/install/templates/shiplog-feature.yml
@@ -181,6 +235,7 @@ files:
181
235
  - lib/shiplog/github/errors.rb
182
236
  - lib/shiplog/github/queries.rb
183
237
  - lib/shiplog/head_tags_helper.rb
238
+ - lib/shiplog/previews/shiplog/newsletter_mailer_preview.rb
184
239
  - lib/shiplog/version.rb
185
240
  - lib/tasks/shiplog.rake
186
241
  homepage: https://github.com/Trackberry/shiplog