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
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a624887b92e388e7234f1c9051437c7c8732b624f448ae4ed6a50834668835b3
4
- data.tar.gz: 1a0f18b1f55b919bf9fcf04da75379666428a49fd312c75ea55c6d064e613959
3
+ metadata.gz: 206caa4e4f1cb0fd09d97cb6f9814711717be5ec45390818e2d6c696f35a9616
4
+ data.tar.gz: caecf4ba5dbc89c540c9d61c6068bbd619a4060ca4d2754708f749db6925ca09
5
5
  SHA512:
6
- metadata.gz: 3dd499eff9f0656078de85134f584ee13eddea6e98d9ad431d363da5bb4c53ce8c06c784914fc4ad2fdf5f30a4a2bc9c53632eed87b3dae1bbbd907efb1cfc88
7
- data.tar.gz: c2322fb8cde5158aa51656e04d885faede534ab939e0a83677245ea1ab6130d11806cc7145f456f9b211b6b8fe7d2b7e821c198261660b23fe974db1dfee8226
6
+ metadata.gz: 9332cc8eda030eccc633b5d24ecd1f5ebcdaf6e1526cae056870db680e2559faa9f71e58c0670aafa7b4f05a05f1fb323a46e39faaa6c480c35d509947f02f60
7
+ data.tar.gz: 3b182cf640dcc187afa77194959712653e91b5ad8187ffe62eb8bd42b813c15ecf9fecd09e248fb23b76cb1e951011531f958facaaeda66c25f2e28ead4c5a49
data/CHANGELOG.md CHANGED
@@ -1,5 +1,41 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.4.0 (2026-10-27)
4
+
5
+ ### Added
6
+
7
+ - Monthly announcements ([#8](https://github.com/Trackberry/shiplog/issues/8)). A new entry kind, `announcement`, summarises a month: an intro, highlights linking to their permalinks and an "Also improved" list by area, in Markdown. `config.rewriter` drafts it from the month's approved features and changes and the small changes that got no entry (changes discarded as not customer-facing, merged pull requests without an entry, minus excluded labels and authors). Its subject is a new `Shiplog::Period` (one row per month), so drafting is idempotent. Without a rewriter the draft is a skeleton of the month's entries.
8
+ - `Shiplog::DraftAnnouncementJob` (schedule it monthly; it drafts the previous month and skips a month with nothing approved), `bin/rails shiplog:draft_announcement MONTH=2026-10`, and "Draft announcement for <month>" buttons in the inbox for the current and previous month.
9
+ - `config.announcements`: `:page` (default) renders `/changelog/2026-10`, links it from the month's heading ("Read the October 2026 summary") and adds it to the Atom feed (not to `?area=` feeds); `:external` publishes no page and links the month heading to `config.announcement_url = ->(announcement, locale) { url }`; `false` turns announcements off. Features and changes never get a `YYYY-MM` slug, so an announcement's URL is known when it is drafted (a pre-0.4.0 entry holding one makes the announcement use `YYYY-MM-update`).
10
+ - `config.on_announcement_approved = ->(announcement) { ... }` and the `announcement_approved.shiplog` notification, from `Shiplog::AnnounceJob`, once an announcement is approved and every configured locale has a current translation; once per approval (`shiplog_entries.announced_at`). Announcements offer `to_markdown(locale)`, `to_html(locale)`, `to_text(locale)` and `public_url`.
11
+ - The announcement's admin page: a rendered preview, Markdown, HTML and plain-text exports per locale, and whether your app was notified. The inbox flags an announcement whose month's entries changed since it was drafted.
12
+ - `Shiplog.unseen_announcements_count(since:)` and `shiplog_unseen_announcements_count(since)`, for a badge on the monthly summary rather than every entry. `config.on_changelog_seen` also runs on announcement pages, and public controllers expose `shiplog_entry` (the entry viewed).
13
+ - `Shiplog::Markdown.to_html` and `.to_text`: a small renderer that escapes everything it does not build (headings, paragraphs, lists, links and images with safe URLs only, emphasis, code, quotes, rules).
14
+ - New `config.classes` keys: `announcement`, `announcement_title`, `announcement_body`, `month_announcement`, `month_announcement_link`.
15
+ - Newsletter ([#8](https://github.com/Trackberry/shiplog/issues/8), off by default: `config.newsletter`). The approved announcement is emailed, in each recipient's language, to the host's users who opted in (`config.newsletter_recipients`, a callable; Shiplog stores nothing about them) and to the visitors who subscribed through Shiplog's form (`shiplog_newsletter_subscribers`, double opt-in with a 7-day signed link, the same answer whatever the address's state, a honeypot and `rate_limit` per address and IP). The form is on the changelog and announcement pages and `shiplog_newsletter_form` (`Shiplog::NewsletterHelper`) renders it anywhere.
16
+ - Unsubscribing: every newsletter carries the RFC 8058 one-click headers pointing at `POST /shiplog/newsletter/unsubscribe/<encrypted email>`, one endpoint for both lists, which records a suppression row in Shiplog (holding that one email, also for the host's users) and calls `config.on_newsletter_unsubscribe = ->(email) { ... }`; a suppressed address is skipped at send time whatever the host's list says, and `Shiplog::Newsletter.resubscribe(email)` lifts the suppression when a user opts in again. The footer links the same page (a button, nothing changes on a GET), `config.newsletter_unsubscribe_url` for the host's users, and a token-based preferences page for subscribers (language, unsubscribe, resubscribe, delete my data; the token is rotated after each change). The unsubscribe and preferences pages keep working while the newsletter is off. Tokens are encrypted (nothing readable in links or logs); `Shiplog::Newsletter::Tokens.rotate(old_secret_key_base)` keeps old links valid after a secret rotation. Confirmation links are single use. Log lines and the rate-limit cache carry digests of addresses, never addresses.
17
+ - Sending, from the announcement's admin page: "Send a test" to the admin's address in a chosen locale (delivered in the request, so no job holds the address), and "Send…" with a confirmation step (recipients per locale) that enqueues `Shiplog::SendNewsletterJob`. Send is disabled, with the reasons shown, until the announcement is approved, every configured locale has a current translation, and `config.newsletter_from`, `config.newsletter_address` and `config.canonical_url` are set. One email per recipient, at most once (`shiplog_newsletter_deliveries`: recipient key, locale and outcome, no address, inserted before the send and purged after 180 days), merged and deduplicated by email (the host's recipient wins), read in batches of `config.newsletter_batch_size` (500) and claimed 100 at a time; sent, failed and pending counts, and "Retry failed" (an atomic reclaim; a reclaimed recipient that is gone ends as failed). Send, Send test and Retry disable their button once submitted (a small `once.js`; the server is idempotent without it).
18
+ - The newsletter email: a hero (wordmark or logo, month label, title, intro), one highlight card per feature of the month (up to three: screenshot, title, first sentence, "See how it works" button to its page; `Newsletter::Content#highlights`), the rest of the body ("Also improved") and the changelog button. The cards replace the body's first `## ` section when the body has two or more (the split rule, documented in the README); otherwise the body is shown whole. Overriding templates get `@content.highlights`, `intro_html`, `rest_html` and `highlights_shown?`.
19
+ - `Shiplog::NewsletterMailer` (HTML in one 600px table column with inline styles and a text part, `List-Unsubscribe` and `List-Unsubscribe-Post` headers, `config.newsletter_mailer_options` such as Postmark's `message_stream`, branding through `config.newsletter_logo_url` and `config.newsletter_accent`), its overridable templates under `shiplog/newsletter_mailer/`, and `Shiplog::NewsletterMailerPreview` (add `lib/shiplog/previews` to `action_mailer.preview_paths`). `Shiplog::Markdown.to_html` takes `styles:` for inline-styled tags.
20
+ - The admin's Newsletter page: subscriber counts per locale and status, a searchable list with remove, and a CSV export that neutralises formula-looking cells. `Shiplog::PurgeNewsletterJob` and `bin/rails shiplog:purge_newsletter` delete the pending subscribers older than 30 days.
21
+ - New `config.classes` keys: `newsletter`, `newsletter_title`, `newsletter_hint`, `newsletter_form`, `newsletter_label`, `newsletter_input`, `newsletter_button`, `newsletter_notice`. New I18n keys under `shiplog.newsletter` and `shiplog.admin.newsletter` (en and es).
22
+
23
+ ### Changed
24
+
25
+ - `Shiplog::Drafter.draft` takes extra attributes for a new entry. The changelog's `_month` partial takes an optional `announcement:` local. Translations keep Markdown and link URLs.
26
+ - `Shiplog::Period.month_name(date, locale:)` names a month in any locale (English without month names); `Period#title` uses it. `Shiplog::PublicLinks.path` gives an engine route's path with the mount prefix.
27
+
28
+ ### Upgrading
29
+
30
+ - Run `bin/rails shiplog:install:migrations` and `bin/rails db:migrate` (adds `shiplog_periods` and `shiplog_entries.announced_at`, then `shiplog_newsletter_subscribers` and `shiplog_newsletter_deliveries`). Rails reports no pending migration for an engine's migrations that were never copied.
31
+ - To send the newsletter: `config.newsletter = true`, `newsletter_from`, `newsletter_address` and `canonical_url`, plus `newsletter_recipients` and `on_newsletter_unsubscribe` for your own users, and `Shiplog::Newsletter.resubscribe(email)` where a user opts in again (see the README's Newsletter section). Give production a real `Rails.cache` (the subscribe form's rate limits count with it) and schedule `Shiplog::PurgeNewsletterJob` weekly. The newsletter's public pages live under `/shiplog/newsletter/`: a route constraint around the engine's mount must let `/shiplog/` paths through, as the webhook already needs. Shiplog stores one row per address that unsubscribes, your users' included: mention it in your privacy policy.
32
+ - With a custom `config.public_layout` or `config.classes`, style the `shiplog-newsletter*` classes (the subscribe form on the changelog and announcement pages, and the newsletter's pages). A host overriding `shiplog/changelogs/show` or `shiplog/changelog_entries/announcement` should render `shiplog/newsletter/form` to offer the form.
33
+ - A custom `config.prompt_builder` now also receives `kind: "announcement"`, with a different context (`month`, `entries`, `skipped`). Return `nil` for it to use the built-in prompt, or build an announcement prompt asking for a Markdown body.
34
+ - With `config.announcements = :page`, `/changelog/YYYY-MM` answers once that month's announcement is approved. A host route of that shape below the changelog path, declared after the mount, is shadowed for those months.
35
+ - Area detection never classifies announcements, and the changelog's "small changes" for an announcement leave out pull requests of a public issue's tree.
36
+ - Schedule `Shiplog::DraftAnnouncementJob` (see the README's Scheduling section), or set `config.announcements = false`.
37
+ - With a custom `config.public_layout` or `config.classes`, style the new classes. A host overriding `shiplog/changelogs/_month` should render the announcement link (see the engine's partial).
38
+
3
39
  ## 0.3.2 (2026-10-26)
4
40
 
5
41
  0.3.1 hosts should upgrade.
data/README.md CHANGED
@@ -122,6 +122,19 @@ All settings live in `config/initializers/shiplog.rb`:
122
122
  | `images` | `true` | [Screenshots](#screenshots) in entries. Effective only when Active Storage is loaded in the host; `false` turns fetching, upload and rendering off. `true` or `false`, anything else raises `ArgumentError`. |
123
123
  | `max_images_per_entry` | `4` | How many images are fetched from the GitHub body of a draft. A non-negative Integer; `0` fetches none (admin uploads are not capped). |
124
124
  | `locales` | `[]` | Locales entries are translated into when approved, e.g. `[:es]`. An Array of Symbols or Strings (stored as symbols; the default locale `I18n.default_locale` is dropped); anything else raises `ArgumentError`. See [Translations](#translations). Part of the ETag. |
125
+ | `announcements` | `:page` | Where [monthly announcements](#monthly-announcements) are published: `:page`, `:external` or `false`. Anything else raises `ArgumentError`. Part of the ETag. |
126
+ | `announcement_url` | `nil` | With `announcements = :external`, `->(announcement, locale) { url }` evaluated in the view: where the month heading's summary link goes. |
127
+ | `on_announcement_approved` | `nil` | `->(announcement) { ... }`, called once an announcement is approved and translated. See [Monthly announcements](#monthly-announcements). |
128
+ | `newsletter` | `false` | Emails the approved announcement to your opted-in users and to the visitors who subscribed. `true` or `false`, anything else raises `ArgumentError`. Needs announcements. Part of the ETag. See [Newsletter](#newsletter). |
129
+ | `newsletter_recipients` | `nil` | `-> { User.where(newsletter: true) }`: your users who opted in, each responding to `email`, `locale` and `id` (a relation is read in batches). Never stored. |
130
+ | `newsletter_unsubscribe_url` | `nil` | `->(recipient) { url }`: your own settings page, linked as "Preferences" in your users' emails. Must be an absolute http(s) URL; a failure is logged and the footer links Shiplog's page. |
131
+ | `on_newsletter_unsubscribe` | `nil` | `->(email) { ... }`, called with the lowercased address on every unsubscribe (one-click, link or preferences page) so your list follows; Shiplog suppresses the address itself either way. Errors are logged. Warned about at boot when missing with `newsletter_recipients`. |
132
+ | `newsletter_from` | `nil` | The sender, e.g. `"Acme <updates@acme.com>"`; also used by the confirmation email. Falls back to `ActionMailer::Base.default[:from]`. Required to send. |
133
+ | `newsletter_address` | `nil` | The postal address in the footer, required to send (CAN-SPAM). |
134
+ | `newsletter_logo_url` | `nil` | The logo in the email, an absolute http(s) URL shown 140px wide. Anything else raises `ArgumentError`. |
135
+ | `newsletter_accent` | `nil` | The colour of buttons and links in the email, `"#rrggbb"` (default `#0969da`). Anything else raises `ArgumentError`. |
136
+ | `newsletter_mailer_options` | `{}` | A Hash merged into every newsletter message's `mail(...)`: `{ message_stream: "broadcast" }` for a Postmark broadcast stream, `delivery_method_options`, ... Keys that would change the recipients, the sender or the content (`to`, `cc`, `bcc`, `from`, `reply_to`, `subject`, `body`, `template_name`, ...) raise `ArgumentError`. Not applied to the confirmation email. |
137
+ | `newsletter_batch_size` | `500` | How many recipients a send reads at a time (1 to 10,000); rows are claimed and emailed 100 at a time within. |
125
138
  | `max_areas` | `8` | How many [product areas](#product-areas) the AI may create. Past it, entries can only join existing areas. A non-negative Integer; `0` stops the AI creating areas. |
126
139
 
127
140
  Example:
@@ -205,10 +218,12 @@ Requests are verified with `X-Hub-Signature-256`; an invalid signature returns 4
205
218
 
206
219
  ## Scheduling
207
220
 
208
- Webhooks can be missed, so schedule two jobs:
221
+ Webhooks can be missed, so schedule these jobs:
209
222
 
210
223
  - `Shiplog::SyncJob`: reconciles issues and pull requests and creates missing drafts. Every 30 minutes is a good start. The first run backfills everything.
211
224
  - `Shiplog::ScanChangesJob`: looks at default-branch commits without a pull request, then drafts small changes. Run it weekly.
225
+ - `Shiplog::DraftAnnouncementJob`: drafts the previous month's [announcement](#monthly-announcements). Run it early each month, after the month's last weekly scan if you can.
226
+ - `Shiplog::PurgeNewsletterJob` (with `config.newsletter`): deletes the [newsletter](#newsletter) subscribers who never confirmed within 30 days and the delivery rows older than 180 days. Run it weekly.
212
227
 
213
228
  GoodJob (`config/initializers/good_job.rb` or `config/application.rb`):
214
229
 
@@ -216,7 +231,9 @@ GoodJob (`config/initializers/good_job.rb` or `config/application.rb`):
216
231
  config.good_job.enable_cron = true
217
232
  config.good_job.cron = {
218
233
  shiplog_sync: { cron: "*/30 * * * *", class: "Shiplog::SyncJob" },
219
- shiplog_scan_changes: { cron: "0 6 * * 1", class: "Shiplog::ScanChangesJob" }
234
+ shiplog_scan_changes: { cron: "0 6 * * 1", class: "Shiplog::ScanChangesJob" },
235
+ shiplog_announcement: { cron: "0 7 2 * *", class: "Shiplog::DraftAnnouncementJob" },
236
+ shiplog_purge_newsletter: { cron: "0 5 * * 1", class: "Shiplog::PurgeNewsletterJob" }
220
237
  }
221
238
  ```
222
239
 
@@ -230,6 +247,12 @@ production:
230
247
  shiplog_scan_changes:
231
248
  class: Shiplog::ScanChangesJob
232
249
  schedule: every monday at 6am
250
+ shiplog_announcement:
251
+ class: Shiplog::DraftAnnouncementJob
252
+ schedule: at 7am on the 2nd day of every month
253
+ shiplog_purge_newsletter:
254
+ class: Shiplog::PurgeNewsletterJob
255
+ schedule: every monday at 5am
233
256
  ```
234
257
 
235
258
  You can also trigger a sync from the admin Sync page. Rewriting always runs in `Shiplog::RewriteEntryJob`, never in a request.
@@ -487,6 +510,150 @@ The count is one `COUNT` query over the `(status, approved_at)` index. It filter
487
510
 
488
511
  `on_changelog_seen` runs **before the page renders**, on the changelog index and on permalink pages whose entry exists, for 200 and 304 answers alike (a visitor whose browser revalidates its cached page has still seen the changelog). Running first means a navbar badge rendered in the same response shows the count after your update. It does not run for 404s (an unknown permalink, a page past the last one), the feed, the roadmap or non-HTML formats. Because it runs on every visit and **may write**, keep it cheap: write only when the stored time is older than the newest entry, as in the example above (one `COUNT`, and a write only when there is something new). The response is not sent yet, so the callback can still set a cookie. An exception in the callback is logged with its class, message and the first five backtrace lines, never shown. A host layout that shows the unseen count renders per-user content, so declare `etag { current_user&.changelog_seen_at }` on your `public_parent_controller`, or a cached page keeps showing the old count.
489
512
 
513
+ ## Monthly announcements
514
+
515
+ Once a month, Shiplog drafts a short **announcement** of what shipped: an intro, two or three highlights linking to their permalink pages, and an "Also improved" list grouped by product area. Your `config.rewriter` writes it, in Markdown, from the month's approved features and changes and from the small changes that got no entry of their own (changes the AI judged not customer-facing, and merged pull requests without an entry). An admin edits and approves it like any other entry. Nothing is public before that.
516
+
517
+ The changelog stays the daily stream. The announcement is its monthly summary.
518
+
519
+ **Drafting.** Schedule `Shiplog::DraftAnnouncementJob` early each month (see [Scheduling](#scheduling)). It drafts the previous month, once: running it again does nothing. A month without an approved feature or change gets no draft. The inbox also has "Draft announcement for <month>" buttons for the current and the previous month, and `bin/rails shiplog:draft_announcement MONTH=2026-10` drafts any month. Without a rewriter, the draft is a skeleton listing the month's entries with links, ready to edit, and Regenerate rebuilds it from the month's current entries. A month drafted from the button before it ended is not drafted again by the job: regenerate it once the month is over.
520
+
521
+ An announcement belongs to a month, so its page is `/changelog/2026-10`. The `YYYY-MM` slug is set when it is drafted, and features and changes never get a slug of that shape. If a month's entries change after the draft was written, the inbox flags "The month's entries changed since this draft": regenerate it, or edit it. Regenerating an approved announcement returns it to draft, as for other entries.
522
+
523
+ **Where it is published** is `config.announcements`:
524
+
525
+ | Value | What happens |
526
+ |---|---|
527
+ | `:page` (default) | Shiplog renders `/changelog/2026-10`, links it from the month's heading on the changelog ("Read the October 2026 summary") and adds it to the Atom feed. |
528
+ | `:external` | No Shiplog page. The month's heading links to `config.announcement_url`, and your app publishes the text itself through the hook below. |
529
+ | `false` | No announcements: nothing is drafted, rendered or announced. |
530
+
531
+ ```ruby
532
+ config.announcements = :external
533
+ config.announcement_url = ->(announcement, locale) { main_app.blog_post_path(announcement.slug, locale:) }
534
+ ```
535
+
536
+ `announcement_url` is evaluated in the view, so route helpers work. A nil or unsafe result renders no link. Bump `config.cache_version` when its output changes.
537
+
538
+ **The approval hook.** `config.on_announcement_approved` is called with the announcement once it is approved **and** translated into every configured locale (right after approval with no `config.locales`, or with no rewriter to translate). It runs once per approval, in `Shiplog::AnnounceJob`: an announcement regenerated and approved again is announced again. The hook runs inside the announcement's row lock and is only marked done when it returns, so a hook that raises, or a worker that dies during it, is called again by the retry (twice at most). Keep it fast and idempotent: enqueue your own job, as below. The same moment sends an `announcement_approved.shiplog` ActiveSupport notification with `announcement:` in the payload. If the hook gave up, or a translation never arrived, the admin page has a "Notify now" button, which does not wait for translations (missing ones fall back to English). Editing an approved announcement's text does not call the hook again.
539
+
540
+ ```ruby
541
+ config.on_announcement_approved = ->(announcement) { BlogImportJob.perform_later(announcement.id) }
542
+ ```
543
+
544
+ The announcement is a `Shiplog::Entry` that offers what you need to publish it elsewhere, in the default locale or any configured one:
545
+
546
+ ```ruby
547
+ announcement.display_title(:es) # the Spanish title, or English without a current translation
548
+ announcement.to_markdown(:es) # the body as written (Markdown)
549
+ announcement.to_html(:es) # rendered and escaped, safe to embed
550
+ announcement.to_text(:es) # plain text, e.g. for an email's text part
551
+ announcement.public_url # its Shiplog page (absolute with config.canonical_url)
552
+ announcement.slug # "2026-10"
553
+ ```
554
+
555
+ **Export.** The announcement's admin page has a preview, and Markdown, HTML and plain-text downloads in each locale, for blogs and newsletter tools without an API.
556
+
557
+ **Badge.** `Shiplog.unseen_announcements_count(since:)` (and `shiplog_unseen_announcements_count(since)` in views) counts approved announcements, where `Shiplog.unseen_count` counts every feature and change. Badge the rare monthly summary rather than every daily entry. `config.on_changelog_seen` also runs on an announcement's page, and `controller.shiplog_entry` is the entry being viewed, so you can stamp a separate "announcements seen" time:
558
+
559
+ ```ruby
560
+ config.on_changelog_seen = lambda do |controller|
561
+ user = controller.send(:current_user)
562
+ next unless user
563
+
564
+ if controller.shiplog_entry&.announcement?
565
+ user.update_column(:announcements_seen_at, Time.current) if Shiplog.unseen_announcements_count(since: user.announcements_seen_at).positive?
566
+ elsif Shiplog.unseen_count(since: user.changelog_seen_at).positive?
567
+ user.update_column(:changelog_seen_at, Time.current)
568
+ end
569
+ end
570
+ ```
571
+
572
+ **Translations** follow the entries' flow: an approved announcement is translated into `config.locales`, keeping its Markdown and links.
573
+
574
+ **Styling.** The page uses `shiplog-announcement`, `shiplog-announcement__title` and `shiplog-announcement__body`; the month link uses `shiplog-month__announcement` and `shiplog-month__announcement-link`. All are `config.classes` keys. The body's HTML comes from a small built-in Markdown renderer (headings, paragraphs, lists, links, images, emphasis, code, quotes) that escapes everything else.
575
+
576
+ **Prompt.** `config.prompt_builder` receives `kind: "announcement"` and a context Hash: `month`, `entries` (each with `kind`, `title`, `body`, `area`, `url`) and `skipped` (titles). Ask for `{"title": "...", "body": "..."}` with a Markdown body, or return `nil` for this kind to keep the built-in prompt.
577
+
578
+ **Links** in the draft are the entries' permalink URLs, absolute when `config.canonical_url` is set (set it: the text travels to the feed, exports and your hook). With the engine mounted under an optional segment (`scope "(:locale)"`), they are the default locale's URLs.
579
+
580
+ ## Newsletter
581
+
582
+ Optional, off by default. The approved announcement goes out by email, in each recipient's language, to your users who opted in and to the visitors who subscribed through Shiplog's form. Approving publishes the announcement; sending the newsletter is a separate, explicit action on the announcement's admin page.
583
+
584
+ **Setup.**
585
+
586
+ ```ruby
587
+ Shiplog.configure do |config|
588
+ config.newsletter = true
589
+ config.newsletter_from = "Acme <updates@acme.com>"
590
+ config.newsletter_address = "Acme Inc., 123 Main Street, Springfield" # the footer's postal address (CAN-SPAM)
591
+ config.canonical_url = "https://acme.com" # the links in emails are absolute
592
+ config.newsletter_logo_url = "https://acme.com/logo.png" # optional branding
593
+ config.newsletter_accent = "#0969da"
594
+ end
595
+ ```
596
+
597
+ Then run the migrations (`bin/rails shiplog:install:migrations && bin/rails db:migrate`): the newsletter adds `shiplog_newsletter_subscribers` and `shiplog_newsletter_deliveries`. The admin nav gets a Newsletter page, and the changelog and announcement pages get a subscribe form. At boot, Shiplog logs what is still missing to send.
598
+
599
+ **Recipients: your users.** Shiplog never stores them. Give it a callable returning objects that respond to `email`, `locale` and `id` (an Active Record relation is read in batches), and two hooks so an unsubscribe reaches your side too:
600
+
601
+ ```ruby
602
+ config.newsletter_recipients = -> { User.where(newsletter: true) }
603
+ config.newsletter_unsubscribe_url = ->(user) { Rails.application.routes.url_helpers.settings_url(host: "acme.com") } # optional
604
+ config.on_newsletter_unsubscribe = ->(email) { User.where("lower(email) = ?", email).update_all(newsletter: false) }
605
+ ```
606
+
607
+ You own the opt-in, the settings toggle and the locale: resolve it yourself (for Trackberry: the user's locale, then the organization's default, then English). A locale that is not the default nor one of `config.locales` gets the default language. Objects without a usable email or id are skipped with a warning (which names no address).
608
+
609
+ How an unsubscribe works for your users: every newsletter carries the RFC 8058 one-click headers (`List-Unsubscribe` and `List-Unsubscribe-Post`, which Gmail and Yahoo require from bulk senders) pointing at Shiplog's own endpoint, `POST /shiplog/newsletter/unsubscribe/<token>`, where the token is the recipient's email, encrypted and signed with a key derived from your app's `secret_key_base` (nothing in a link or a log reveals the address). So Shiplog needs no row for your users until they unsubscribe, and one endpoint serves both lists: it records the unsubscribe in Shiplog, as a *suppression row* in `shiplog_newsletter_subscribers` holding that one email (created when the address has no row, marked when it has), and calls `on_newsletter_unsubscribe` with the lowercased email so your own setting follows. From then on the address is skipped at send time even if your list still returns it: an unsubscribe is honoured whether or not your hook works. Set the hook anyway (Shiplog warns at boot when `newsletter_recipients` is set without it), or your settings page will show the user as subscribed. When a user opts in again in your app, lift the suppression:
610
+
611
+ ```ruby
612
+ Shiplog::Newsletter.resubscribe(user.email) # true when the address was suppressed
613
+ ```
614
+
615
+ A suppression-only row is deleted (nothing is left to store); a visitor who had confirmed through Shiplog's form is confirmed again. The footer's "Unsubscribe" link opens the same endpoint as a page with a button (a GET changes nothing: mail scanners prefetch links). The footer's "Preferences" link is `newsletter_unsubscribe_url` when you set it (your own settings page, evaluated with the recipient object), else Shiplog's unsubscribe page. These pages keep working after you turn `config.newsletter` off (or announcements): a link in an old email must still unsubscribe. If you rotate `secret_key_base`, call `Shiplog::Newsletter::Tokens.rotate(old_secret_key_base)` in an initializer while old links must keep working.
616
+
617
+ **Recipients: visitors.** The subscribe form is on the changelog and on each announcement page, and you can render it on any page:
618
+
619
+ ```erb
620
+ <%# a host view; add `helper Shiplog::NewsletterHelper` to the controller %>
621
+ <%= shiplog_newsletter_form %>
622
+ ```
623
+
624
+ One email field, a hidden honeypot, a plain POST that works without JavaScript. Double opt-in: the form creates a pending subscriber with the page's locale (if it is the default or one of `config.locales`) and the page it was on (`source`, a consent record: a same-site path, anything else is dropped), and emails a confirmation link valid 7 days; the link opens a page with a button, and the button confirms. The link is single use (an encrypted nonce, spent when it confirms, voided by an unsubscribe, replaced when a new confirmation is sent), so a replayed link resubscribes nobody. Only confirmed subscribers receive the newsletter. The form answers the same thing for a new, pending, confirmed or unsubscribed address, so it reveals nothing; a confirmed address gets no new email, and an existing row keeps its locale and source (an anonymous submission cannot rewrite them). Attempts are rate limited per address (3 an hour, keyed by a digest of the address without `+tag` and, for Gmail, without dots), per IP (20 an hour) and overall (300 confirmation emails an hour) through `Rails.cache`: with a null store nothing is counted, so give production a real cache store (Shiplog warns at boot otherwise). The subscribe POST carries no CSRF token and checks none: the pages carrying the form are served with ETags, so a cached page could hold a stale token, and nothing happens until the address confirms by email.
625
+
626
+ Every newsletter links a preferences page, reached through a token in the URL without an account, where a subscriber changes the language, unsubscribes, resubscribes (no new confirmation: the token proves the mailbox) or deletes their data. The token is rotated after each change there (the page continues with the new one; the link in the old email stops working, the next newsletter carries the new one). Unsubscribed rows stay as a suppression record; subscribing again through the form means confirming again. Schedule `Shiplog::PurgeNewsletterJob` weekly (see [Scheduling](#scheduling)): it deletes pending rows untouched for 30 days and delivery rows older than 180 days. `bin/rails shiplog:purge_newsletter` does the same.
627
+
628
+ **One person on both lists.** Recipients are merged at send time and deduplicated by lowercased email; when an address is on both lists, your user wins (its locale, its preferences link). Any unsubscribe removes the address from both.
629
+
630
+ **Sending.** On an approved announcement's admin page: "Send a test to <your address>" (`config.current_admin`'s email, in a chosen locale; works before approval) and "Send…", which opens a confirmation step with the recipient count per locale and sends through `Shiplog::SendNewsletterJob`. Send stays disabled, with the reasons on the page, until the announcement is approved, every configured locale has a current translation (no stale translation is ever sent), and `newsletter_from`, `newsletter_address` and an origin for links (`canonical_url`, else `action_mailer.default_url_options`) are set. The job renders the body once per locale and emails each recipient once: a `shiplog_newsletter_deliveries` row (unique on announcement and recipient key, no address) is inserted *before* the email leaves, 100 recipients at a time, and touched right before each send, so a retried job, two workers or a second click never send twice. This is at-most-once: a worker dying between the insert and the send leaves a pending row, never a duplicate. The page shows sent, failed and pending counts; "Retry failed" reclaims, in one statement, the failed rows and the rows left pending for more than an hour, and sends them again (the one case where a recipient may get the email twice, and you trigger it knowingly); a reclaimed row whose recipient is gone (unsubscribed, deleted) ends as failed with that reason. Failures are recorded as the exception's class and its message with anything address-like removed. One newsletter per announcement: a later Send only reaches recipients added since.
631
+
632
+ **Deliverability.** Send bulk mail through a separate stream, so a complaint never hurts your transactional email. With Postmark:
633
+
634
+ ```ruby
635
+ config.newsletter_mailer_options = { message_stream: "broadcast" }
636
+ config.newsletter_batch_size = 500 # the default; how many recipients a run claims at a time
637
+ ```
638
+
639
+ `newsletter_mailer_options` is merged into every newsletter message (not the confirmation email, which is transactional and uses your default delivery settings): ActionMailer options (`delivery_method`, `delivery_method_options`) or headers your delivery gem reads. Also: set up SPF, DKIM and DMARC for the sending domain, keep the postal address in the footer, and let Postmark (or your provider) suppress bounces and complaints per stream. Shiplog adds `Precedence: bulk` and the one-click headers; it does no open or click tracking.
640
+
641
+ **Design.** `Shiplog::NewsletterMailer` builds an HTML part (one 600px table column, fluid on phones, every style inline, absolute image URLs with width and alt, light colours that email clients invert sanely in dark mode) and a text part. The email: a logo row (or the product name as a wordmark) with "View in browser", a hero card with "Monthly update · October 2026", the title and the intro, then one **highlight card** per feature of the month (up to three, in the changelog's order): its first screenshot full width when it has one, its title, its first sentence and a "See how it works" button to its page; then a card with the rest of the announcement ("Also improved", grouped by area), a "See everything that shipped" button, and the footer with unsubscribe, preferences and the postal address. The subject, title and body are the announcement's translation in the recipient's locale (English without one); the chrome uses Shiplog's I18n keys under `shiplog.newsletter` (en and es shipped). "View in browser" opens the announcement's page (`config.announcement_url`, called with the entry and the locale, when announcements are `:external`).
642
+
643
+ **Highlights and the body.** The cards come from the month's approved features themselves (their approved, translated title and body, their screenshot and permalink; features without a permalink are skipped), not from the announcement's text, so an edit to the body's highlights section does not show in the cards. To avoid saying everything twice, the email applies a split rule: when there are highlights and the body has at least two `## ` sections, it shows the intro (the text before the first heading), the cards in place of the body's *first* section, and the body from its *second* section on. Otherwise (no feature with a permalink, or a body without that structure) the whole body is shown as written. The split is made on the Markdown of the locale's text, so translated headings work the same. Screenshots need `config.images`; their URLs go through Active Storage's redirect route of your app at the email's origin (`canonical_url`), and a screenshot whose URL cannot be built is left out with a log line.
644
+
645
+ **Customising the email.** Copy `app/views/shiplog/newsletter_mailer/announcement.{html,text}.erb` (and `confirmation.{html,text}.erb`) into your app, both parts of each: Rails takes a mailer's templates from the first view path that has any, so an app holding only the HTML part sends a single-part email. The templates get `@content`, a `Shiplog::Newsletter::Content` with `title`, `month`, `preheader`, `html` (the whole body, inline-styled), `highlights_shown?` (the split rule applies), `intro_html` / `intro_text`, `highlights` (each with `title`, `summary`, `url`, `image_url`, `image_alt`, `image_width`, `image_height`, `image?`), `rest_html` / `rest_text`, `text`, `view_url`, `changelog_url`, `accent`, `logo_url`, `address` and `product_name`; plus `@unsubscribe_url` and `@preferences_url`. `Shiplog::NewsletterMailerHelper` offers `shiplog_mail_palette`, `shiplog_mail_font`, `shiplog_mail_button(label, url, accent)` and `shiplog_mail_screenshot(highlight)`, and `Shiplog::Markdown.to_html(text, styles: { "p" => "..." })` renders Markdown with inline styles. An override must keep the unsubscribe link (`@unsubscribe_url`), the preferences link and the postal address (`@content.address`): the `List-Unsubscribe` headers are set by the mailer, but the footer is the recipient's visible way out and the address is required by CAN-SPAM. To preview locally, add the previews to `config/environments/development.rb` and open `/rails/mailers/shiplog/newsletter_mailer` (the sample shows three cards without any data):
646
+
647
+ ```ruby
648
+ config.action_mailer.preview_paths << Shiplog::Engine.root.join("lib/shiplog/previews").to_s
649
+ ```
650
+
651
+ **Admin.** The Newsletter page lists the subscribers (counts per locale and status, search, remove) and downloads them as CSV (cells that a spreadsheet would read as a formula are neutralised). Your own users are not listed: they are yours.
652
+
653
+ **What Shiplog stores, and consent.** In `shiplog_newsletter_subscribers`: the visitors who subscribed through its form (email, locale, the page they subscribed on, when they subscribed, confirmed and unsubscribed, the preferences token and a pending confirmation nonce), and one row per address that unsubscribed, your users' included: the suppression record that keeps the address out of future sends, holding the email and the time (no locale of yours, no source). Nothing else about your users, and no tracking of opens or clicks. Delivery rows (`shiplog_newsletter_deliveries`) keep the recipient key (`User/42` or `Subscriber/7`), the locale and the outcome, never the address; they are deleted with a subscriber's row, with the announcement, and after 180 days by the purge job. Log lines name recipient keys or a digest of the address, never the address; the rate-limit cache holds digests. A suppression row stays until the person deletes their data from the preferences page, an admin removes it, or your app calls `Shiplog::Newsletter.resubscribe`. No queued job carries an address: a test send is delivered in the request, a confirmation's job carries the subscriber's id, and the send job reads its recipients when it runs. Mention the newsletter in your privacy policy.
654
+
655
+ **Styling.** The form and pages use `shiplog-newsletter` with `__title`, `__hint`, `__form`, `__label`, `__input`, `__button` and `__notice`, all `config.classes` keys (`newsletter`, `newsletter_title`, ...). The honeypot sits in `shiplog-newsletter__trap`, which the bundled stylesheet moves out of view: keep a rule for it if you style the pages yourself.
656
+
490
657
  ## Styling
491
658
 
492
659
  The public pages ship plain semantic HTML with stable [BEM](https://getbem.com) classes (`shiplog-card`, `shiplog-entry__title`, ...) and a small, framework-free stylesheet. Pick the level that fits your app; the same views work in all of them.
@@ -624,6 +791,7 @@ Copy any view from the engine's `app/views/shiplog/` into your app with the same
624
791
  - `shiplog/_switcher.html.erb`
625
792
  - `shiplog/roadmaps/_column.html.erb`, `shiplog/roadmaps/_card.html.erb`, `shiplog/roadmaps/_shipped.html.erb`
626
793
  - `shiplog/changelogs/_month.html.erb`, `shiplog/changelogs/_entry.html.erb`, `shiplog/changelogs/_search.html.erb`
794
+ - `shiplog/newsletter/_form.html.erb` (the subscribe form), `shiplog/newsletter/_page.html.erb` (the confirm, preferences and unsubscribe pages' frame), and the emails in `shiplog/newsletter_mailer/`
627
795
 
628
796
  `shiplog/_page.html.erb` is what links the stylesheet (so it also works in layouts you don't control); keep that line if you copy it and still want the bundled CSS.
629
797
 
@@ -0,0 +1,19 @@
1
+ module Shiplog
2
+ module Admin
3
+ # "Draft announcement for <month>" in the inbox: drafts the current or the previous month (see
4
+ # Announcements.draftable_months) and opens it; a month that has an announcement already opens that one.
5
+ class AnnouncementsController < ApplicationController
6
+ def create
7
+ month = Announcements.draftable_months.find { |candidate| candidate.strftime("%Y-%m") == params[:month].to_s }
8
+ return head(:not_found) unless month && Shiplog.announcements?
9
+
10
+ entry = Announcements.draft(month:) || Period.find_by(starts_on: month)&.announcement
11
+ if entry
12
+ redirect_to edit_entry_path(entry, inbox_filter_params), notice: t("shiplog.admin.announcements.drafted")
13
+ else
14
+ redirect_to root_path(inbox_filter_params), alert: t("shiplog.admin.announcements.nothing_to_announce")
15
+ end
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,18 @@
1
+ module Shiplog
2
+ module Admin
3
+ module Entries
4
+ # "Notify now" on an approved announcement your app was not told about yet: the hook gave up after its retries,
5
+ # or a translation never came. Calls config.on_announcement_approved without waiting for translations (the
6
+ # hook gets the English text where one is missing); an announcement already announced is left alone.
7
+ class AnnouncementNotificationsController < ApplicationController
8
+ def create
9
+ entry = Entry.find(params[:entry_id])
10
+ return head(:not_found) unless entry.announcement?
11
+
12
+ AnnounceJob.perform_later(entry, force: true)
13
+ redirect_to edit_entry_path(entry, inbox_filter_params), notice: t("shiplog.admin.announcements.notify_queued")
14
+ end
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,45 @@
1
+ module Shiplog
2
+ module Admin
3
+ module Entries
4
+ # An announcement's text as a file, for hosts that publish it on their own blog or newsletter tool:
5
+ # ?as=markdown (default), html or text, in the default locale or ?export_locale=<one of config.locales>. The text is
6
+ # the current translation when there is one, else the English text (Entry#to_markdown).
7
+ class ExportsController < ApplicationController
8
+ FORMATS = {
9
+ "markdown" => [ "md", "text/markdown" ],
10
+ "html" => [ "html", "text/html" ],
11
+ "text" => [ "txt", "text/plain" ]
12
+ }.freeze
13
+
14
+ def show
15
+ entry = Entry.find(params[:entry_id])
16
+ return head(:not_found) unless entry.announcement?
17
+
18
+ locale = export_locale
19
+ return head(:not_found) unless locale
20
+
21
+ extension, type = FORMATS.fetch(params[:as].to_s.presence || "markdown") { return head(:not_found) }
22
+ send_data content(entry, extension, locale), type: "#{type}; charset=utf-8", disposition: "attachment",
23
+ filename: "#{entry.slug.presence || "announcement-#{entry.id}"}#{"-#{locale}" unless locale == I18n.default_locale.to_s}.#{extension}"
24
+ end
25
+
26
+ private
27
+
28
+ # The default locale or a configured one; nil for anything else.
29
+ def export_locale
30
+ requested = params[:export_locale].to_s.presence || I18n.default_locale.to_s
31
+ requested if requested == I18n.default_locale.to_s || Entry.translated_locale?(requested)
32
+ end
33
+
34
+ def content(entry, extension, locale)
35
+ title = entry.display_title(locale).to_s
36
+ case extension
37
+ when "md" then "# #{title}\n\n#{entry.to_markdown(locale)}\n"
38
+ when "html" then "<h1>#{ERB::Util.html_escape(title)}</h1>\n#{entry.to_html(locale)}\n"
39
+ else "#{title}\n\n#{entry.to_text(locale)}\n"
40
+ end
41
+ end
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,17 @@
1
+ module Shiplog
2
+ module Admin
3
+ module Entries
4
+ # "Retry failed": runs Shiplog::SendNewsletterJob again for the deliveries that failed, and for the pending
5
+ # ones older than an hour (a worker died with them claimed; see NewsletterDelivery.reclaim).
6
+ class NewsletterRetriesController < ApplicationController
7
+ def create
8
+ entry = Entry.find(params[:entry_id])
9
+ return head(:not_found) unless entry.announcement? && Shiplog.newsletter?
10
+
11
+ SendNewsletterJob.perform_later(entry, retry_failed: true)
12
+ redirect_to edit_entry_path(entry, inbox_filter_params), notice: t("shiplog.admin.newsletter.retry_queued")
13
+ end
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,39 @@
1
+ module Shiplog
2
+ module Admin
3
+ module Entries
4
+ # "Send test": one copy of the newsletter to the current admin's address (config.current_admin, which gives
5
+ # an email by default; nothing can be sent when it gives none), in the chosen locale. No delivery row: a
6
+ # test never counts as a send, and it works before the announcement is approved or translated. The links in
7
+ # it are real (the admin's own unsubscribe link), as in the final email.
8
+ class NewsletterTestsController < ApplicationController
9
+ def create
10
+ entry = Entry.find(params[:entry_id])
11
+ return head(:not_found) unless entry.announcement? && Shiplog.newsletter?
12
+
13
+ email = current_admin_name.to_s.strip
14
+ return back_with_alert(entry, t("shiplog.admin.newsletter.test_no_email")) unless URI::MailTo::EMAIL_REGEXP.match?(email)
15
+
16
+ blockers = Newsletter.blockers(entry, test: true)
17
+ return back_with_alert(entry, t("shiplog.admin.newsletter.send_blocked", reasons: blockers.map { |blocker| t("shiplog.admin.newsletter.blockers.#{blocker}", locales: "") }.join(", "))) if blockers.any?
18
+
19
+ # test_locale, not locale: a host's around_action switching the locale from params[:locale] must not read it.
20
+ locale = NewsletterSubscriber.resolve_locale(params[:test_locale])
21
+ # Delivered now, in the request: a queued mail job would carry the admin's address (and unsubscribe link)
22
+ # into the queue's storage. One message, so the wait is short, and a failure shows at once.
23
+ NewsletterMailer.announcement(entry, email, locale:, unsubscribe_url: Newsletter.unsubscribe_url(email)).deliver_now
24
+ redirect_to edit_entry_path(entry, inbox_filter_params), notice: t("shiplog.admin.newsletter.test_sent", email:, language: locale)
25
+ rescue StandardError => error
26
+ # The class only: a delivery error's message can carry the address or the mail server's details.
27
+ Rails.logger.error("[shiplog] The newsletter test send failed: #{error.class}")
28
+ back_with_alert(entry, t("shiplog.admin.newsletter.test_failed", error: error.class.name))
29
+ end
30
+
31
+ private
32
+
33
+ def back_with_alert(entry, alert)
34
+ redirect_to edit_entry_path(entry, inbox_filter_params), alert:
35
+ end
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,40 @@
1
+ module Shiplog
2
+ module Admin
3
+ module Entries
4
+ # Sending an announcement as the newsletter: `show` is the confirmation step (the recipient count per locale,
5
+ # after deduplication), `create` enqueues Shiplog::SendNewsletterJob. Both refuse while Newsletter.blockers
6
+ # lists a reason (not approved, a missing translation, no sender, no address, no origin for links).
7
+ class NewslettersController < ApplicationController
8
+ before_action :set_announcement
9
+
10
+ def show
11
+ @blockers = Newsletter.blockers(@entry)
12
+ @progress = NewsletterDelivery.progress(@entry)
13
+ @counts = Newsletter::Recipients.new.counts_by_locale
14
+ rescue StandardError => error
15
+ # The page names the class only: a message could carry an address or a secret. The log has the rest.
16
+ Rails.logger.error("[shiplog] Listing the newsletter's recipients failed: #{error.class}: #{NewsletterDelivery.scrub(error.message)}")
17
+ @counts = {}
18
+ @recipients_error = error.class.name
19
+ end
20
+
21
+ def create
22
+ blockers = Newsletter.blockers(@entry)
23
+ if blockers.any?
24
+ redirect_to edit_entry_path(@entry, inbox_filter_params), alert: t("shiplog.admin.newsletter.send_blocked", reasons: blockers.map { |blocker| t("shiplog.admin.newsletter.blockers.#{blocker}", locales: Newsletter.missing_locales(@entry).join(", ")) }.join(", "))
25
+ else
26
+ SendNewsletterJob.perform_later(@entry)
27
+ redirect_to edit_entry_path(@entry, inbox_filter_params), notice: t("shiplog.admin.newsletter.queued")
28
+ end
29
+ end
30
+
31
+ private
32
+
33
+ def set_announcement
34
+ @entry = Entry.find(params[:entry_id])
35
+ head :not_found unless @entry.announcement? && Shiplog.newsletter?
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end
@@ -12,6 +12,7 @@ module Shiplog
12
12
  translation.assign_attributes(translation_params)
13
13
  translation.assign_attributes(edited_by_admin: true, source_digest: @entry.translation_source_digest)
14
14
  if translation.save
15
+ AnnounceJob.perform_later(@entry) if @entry.announcement? # the missing translation may have been this one
15
16
  redirect_to edit_entry_path(@entry, inbox_filter_params), notice: t("shiplog.admin.entries.translations.saved", default: "Translation saved.")
16
17
  else
17
18
  redirect_to edit_entry_path(@entry, inbox_filter_params), alert: translation.errors.full_messages.to_sentence
@@ -42,9 +42,11 @@ module Shiplog
42
42
 
43
43
  private
44
44
 
45
- # Every subject type (issue, pull request, commit) belongs to a repository; load them once per type.
45
+ # Issues, pull requests and commits belong to a repository (periods don't); load them once per type.
46
46
  def preload_repositories(entries)
47
- entries.filter_map(&:subject).group_by(&:class).each_value do |subjects|
47
+ entries.filter_map(&:subject).group_by(&:class).each do |type, subjects|
48
+ next unless type.reflect_on_association(:repository)
49
+
48
50
  ActiveRecord::Associations::Preloader.new(records: subjects, associations: :repository).call
49
51
  end
50
52
  end
@@ -53,8 +55,10 @@ module Shiplog
53
55
  @entry = Entry.find(params[:id])
54
56
  end
55
57
 
58
+ # An announcement's month, release date and area follow from its period: only its text is edited.
56
59
  def entry_params
57
- params.expect(entry: %i[title body released_on changelog_kind area_id])
60
+ permitted = params.expect(entry: %i[title body released_on changelog_kind area_id])
61
+ @entry.announcement? ? permitted.slice(:title, :body) : permitted
58
62
  end
59
63
 
60
64
  def page_number
@@ -0,0 +1,47 @@
1
+ module Shiplog
2
+ module Admin
3
+ # The newsletter's subscribers (the visitors who subscribed through Shiplog's form; the host's users are its
4
+ # own): counts per locale and status, a searchable paginated list, removal, and a CSV export (.csv, the
5
+ # search applied; cells that look like formulas are neutralised, see Newsletter::Csv).
6
+ class NewsletterSubscribersController < ApplicationController
7
+ PER_PAGE = 100
8
+ MAX_PAGE = 100_000
9
+
10
+ before_action :require_newsletter
11
+
12
+ def index
13
+ @query = params[:q].is_a?(String) ? params[:q].squish.first(NewsletterSubscriber::EMAIL_MAX_LENGTH) : ""
14
+ scope = NewsletterSubscriber.search(@query).newest_first
15
+ respond_to do |format|
16
+ format.html do
17
+ @counts = NewsletterSubscriber.counts_by_locale
18
+ @page = page_number
19
+ records = scope.offset((@page - 1) * PER_PAGE).limit(PER_PAGE + 1).to_a
20
+ @more = records.size > PER_PAGE
21
+ @subscribers = records.first(PER_PAGE)
22
+ end
23
+ format.csv do
24
+ send_data Newsletter::Csv.generate(scope), type: "text/csv; charset=utf-8", disposition: "attachment",
25
+ filename: "shiplog-newsletter-subscribers-#{Date.current.iso8601}.csv"
26
+ end
27
+ end
28
+ end
29
+
30
+ def destroy
31
+ subscriber = NewsletterSubscriber.find(params[:id])
32
+ subscriber.destroy!
33
+ redirect_to newsletter_subscribers_path(q: params[:q].presence), notice: t("shiplog.admin.newsletter.removed", email: subscriber.email)
34
+ end
35
+
36
+ private
37
+
38
+ def require_newsletter
39
+ head :not_found unless Shiplog.newsletter?
40
+ end
41
+
42
+ def page_number
43
+ Integer(params[:page].to_s, 10, exception: false).to_i.clamp(1, MAX_PAGE)
44
+ end
45
+ end
46
+ end
47
+ end
@@ -1,11 +1,13 @@
1
1
  module Shiplog
2
- # The permalink page of a published feature or change: /changelog/:slug.
2
+ # The permalink page of a published feature or change, or of a month's announcement: /changelog/:slug.
3
3
  class ChangelogEntriesController < PublicController
4
4
  def show
5
- @entry = Entry.published_changelog.with_images_loaded.find_by(slug: params[:slug])
5
+ @entry = Entry.published_changelog.with_images_loaded.find_by(slug: params[:slug]) || published_announcement
6
6
  return pass_to_host_routes unless @entry
7
7
 
8
8
  notify_changelog_seen
9
+ return show_announcement if @entry.announcement?
10
+
9
11
  @newer = @entry.newer_changelog_entry
10
12
  @older = @entry.older_changelog_entry
11
13
  preload_translations(@entry, @newer, @older)
@@ -15,6 +17,16 @@ module Shiplog
15
17
 
16
18
  private
17
19
 
20
+ # An approved announcement with this slug, when Shiplog renders announcement pages (config.announcements).
21
+ def published_announcement
22
+ Entry.published_announcements.find_by(slug: params[:slug]) if Shiplog.announcement_pages?
23
+ end
24
+
25
+ def show_announcement
26
+ preload_translations(@entry)
27
+ render :announcement if stale?(etag: [ *shiplog_etag_parts, @entry.id, @entry.updated_at, shiplog_translation_etag_parts([ @entry ]) ])
28
+ end
29
+
18
30
  # The engine is usually mounted at "/", so /changelog/:slug can shadow a host route below the changelog
19
31
  # path (/changelog/archive, declared after the mount). An unknown slug answers 404 with X-Cascade: pass,
20
32
  # which makes the router try the routes after the engine's mount; if none matches, the host's own 404 shows.
@@ -1,5 +1,6 @@
1
1
  module Shiplog
2
- # The Atom feed of the latest published features and changes: /changelog.atom.
2
+ # The Atom feed of the latest published features and changes, and of the announcements when Shiplog renders
3
+ # their pages: /changelog.atom.
3
4
  class ChangelogFeedsController < PublicController
4
5
  LIMIT = 50
5
6
 
@@ -7,7 +8,10 @@ module Shiplog
7
8
  before_action :set_area
8
9
 
9
10
  def show
10
- scope = Entry.published_changelog.newest_first
11
+ scope = Entry.published_changelog
12
+ # Announcements have no area: a feed filtered by area leaves them out.
13
+ scope = scope.or(Entry.published_announcements) if Shiplog.announcement_pages? && !@area
14
+ scope = scope.newest_first
11
15
  scope = scope.where(area_id: @area.id) if @area
12
16
  @entries = scope.includes(:area).with_images_loaded.limit(LIMIT).to_a
13
17
  @entries.select!(&:permalink?) # no slug (or an invalid one) means no stable id: left out