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
@@ -20,7 +20,7 @@
20
20
  </p>
21
21
  <p>
22
22
  <%= form.label :body, t("shiplog.admin.entries.fields.body") %>
23
- <%= form.text_area :body, rows: 8 %>
23
+ <%= form.text_area :body, rows: (@entry.announcement? ? 24 : 8) %>
24
24
  </p>
25
25
  <% if Shiplog::Entry::CHANGELOG_KINDS.include?(@entry.kind) %>
26
26
  <p>
@@ -28,6 +28,7 @@
28
28
  <%= form.select :changelog_kind, Shiplog::Entry::CHANGELOG_KINDS.map { |kind| [ t("shiplog.admin.entries.category_options.#{kind}"), kind ] } %>
29
29
  </p>
30
30
  <% end %>
31
+ <% unless @entry.announcement? %>
31
32
  <p>
32
33
  <%= form.label :area_id, t("shiplog.admin.entries.fields.area", default: "Area") %>
33
34
  <%= form.collection_select :area_id, Shiplog::Area.ordered, :id, :name, include_blank: t("shiplog.admin.entries.no_area", default: "None") %>
@@ -36,6 +37,7 @@
36
37
  <%= form.label :released_on, t("shiplog.admin.entries.fields.released_on") %>
37
38
  <%= form.date_field :released_on %>
38
39
  </p>
40
+ <% end %>
39
41
  <%= form.submit t("shiplog.admin.entries.save"), class: "shiplog-button shiplog-button--primary" %>
40
42
  <% end %>
41
43
 
@@ -67,11 +69,15 @@
67
69
  </aside>
68
70
  </div>
69
71
 
72
+ <% if @entry.announcement? %>
73
+ <%= render "shiplog/admin/entries/announcement", entry: @entry %>
74
+ <% end %>
75
+
70
76
  <% if Shiplog.config.locales.any? %>
71
77
  <%= render "shiplog/admin/entries/translations", entry: @entry %>
72
78
  <% end %>
73
79
 
74
- <% if Shiplog.images? %>
80
+ <% if Shiplog.images? && !@entry.announcement? %>
75
81
  <%= render "shiplog/admin/entries/images", entry: @entry %>
76
82
  <% end %>
77
83
 
@@ -1,6 +1,14 @@
1
1
  <%= render layout: "shiplog/admin/page" do %>
2
2
  <h1 class="shiplog-admin__title"><%= t("shiplog.admin.entries.title") %></h1>
3
3
 
4
+ <% if Shiplog.announcements? %>
5
+ <div class="shiplog-admin-actions">
6
+ <% Shiplog::Announcements.draftable_months.each do |month| %>
7
+ <%= button_to t("shiplog.admin.announcements.draft", month: l(month, format: "%B %Y")), announcements_path(month: month.strftime("%Y-%m"), **inbox_filter_params), class: "shiplog-button" %>
8
+ <% end %>
9
+ </div>
10
+ <% end %>
11
+
4
12
  <nav class="shiplog-admin-filters" aria-label="<%= t("shiplog.admin.entries.filters") %>">
5
13
  <ul class="shiplog-admin-filters__list">
6
14
  <% Shiplog::Entry.statuses.each_key do |status| %>
@@ -12,6 +20,7 @@
12
20
  <li><%= link_to t("shiplog.admin.entries.all_kinds"), entries_filter_path(status: @status),
13
21
  class: "shiplog-admin-filters__link", "aria-current": (@kind.nil? ? "true" : nil) %></li>
14
22
  <% Shiplog::Entry.kinds.each_key do |kind| %>
23
+ <% next if kind == "announcement" && !Shiplog.announcements? %>
15
24
  <li><%= link_to t("shiplog.admin.entries.kinds.#{kind}"), entries_filter_path(status: @status, kind:),
16
25
  class: "shiplog-admin-filters__link", "aria-current": (kind == @kind ? "true" : nil) %></li>
17
26
  <% end %>
@@ -0,0 +1,46 @@
1
+ <%# The confirmation step before sending: the recipients per locale (after deduplication), what was sent already, and the Send button %>
2
+ <%= render layout: "shiplog/admin/page" do %>
3
+ <h1 class="shiplog-admin__title"><%= t("shiplog.admin.newsletter.confirm_title", title: @entry.title) %></h1>
4
+
5
+ <% if @blockers.any? %>
6
+ <div class="shiplog-admin__flash shiplog-admin__flash--alert" role="alert">
7
+ <%= t("shiplog.admin.newsletter.send_blocked", reasons: @blockers.map { |blocker| t("shiplog.admin.newsletter.blockers.#{blocker}", locales: Shiplog::Newsletter.missing_locales(@entry).join(", ")) }.join(", ")) %>
8
+ </div>
9
+ <% end %>
10
+
11
+ <section class="shiplog-admin-panel">
12
+ <h2><%= t("shiplog.admin.newsletter.recipients") %></h2>
13
+ <% if @recipients_error %>
14
+ <p class="shiplog-admin__flash shiplog-admin__flash--alert" role="alert"><%= t("shiplog.admin.newsletter.recipients_error", error: @recipients_error) %></p>
15
+ <% elsif @counts.empty? %>
16
+ <p class="shiplog-admin__muted"><%= t("shiplog.admin.newsletter.recipients_none") %></p>
17
+ <% else %>
18
+ <table class="shiplog-admin-table shiplog-admin-counts">
19
+ <thead>
20
+ <tr>
21
+ <th scope="col"><%= t("shiplog.admin.newsletter.columns.locale") %></th>
22
+ <th scope="col"><%= t("shiplog.admin.newsletter.columns.recipients") %></th>
23
+ </tr>
24
+ </thead>
25
+ <tbody>
26
+ <% @counts.each do |locale, count| %>
27
+ <tr><td><%= locale %></td><td class="shiplog-admin-counts__number"><%= count %></td></tr>
28
+ <% end %>
29
+ <tr><th scope="row"><%= t("shiplog.admin.newsletter.total") %></th><td class="shiplog-admin-counts__number"><%= @counts.values.sum %></td></tr>
30
+ </tbody>
31
+ </table>
32
+ <% end %>
33
+ <% total = @counts.values.sum %>
34
+ <% if @progress.values_at(:sent, :failed, :pending).sum.positive? %>
35
+ <p class="shiplog-admin__muted"><%= t("shiplog.admin.newsletter.progress", **@progress.slice(:sent, :failed, :pending)) %> <%= t("shiplog.admin.newsletter.confirm_resend_help") %></p>
36
+ <% end %>
37
+ <p class="shiplog-admin__muted"><%= t("shiplog.admin.newsletter.confirm_help") %></p>
38
+ <div class="shiplog-admin-actions">
39
+ <%= shiplog_javascript_tag "once" %>
40
+ <%= button_to t("shiplog.admin.newsletter.confirm_button", count: total), entry_newsletter_path(@entry, inbox_filter_params), method: :post,
41
+ class: "shiplog-button shiplog-button--primary", disabled: @blockers.any? || total.zero?, form: { data: { shiplog_once: true } } %>
42
+ </div>
43
+ </section>
44
+
45
+ <p><%= link_to t("shiplog.admin.newsletter.back"), edit_entry_path(@entry, inbox_filter_params) %></p>
46
+ <% end %>
@@ -0,0 +1,83 @@
1
+ <%= render layout: "shiplog/admin/page" do %>
2
+ <h1 class="shiplog-admin__title"><%= t("shiplog.admin.newsletter.title") %></h1>
3
+ <p class="shiplog-admin__muted"><%= t("shiplog.admin.newsletter.help") %></p>
4
+
5
+ <table class="shiplog-admin-table shiplog-admin-counts">
6
+ <thead>
7
+ <tr>
8
+ <th scope="col"><%= t("shiplog.admin.newsletter.columns.locale") %></th>
9
+ <% Shiplog::NewsletterSubscriber::STATUSES.each do |status| %>
10
+ <th scope="col"><%= t("shiplog.admin.newsletter.statuses.#{status}") %></th>
11
+ <% end %>
12
+ </tr>
13
+ </thead>
14
+ <tbody>
15
+ <% @counts.sort_by { |locale, _| locale }.each do |locale, counts| %>
16
+ <tr>
17
+ <td><%= locale %></td>
18
+ <% Shiplog::NewsletterSubscriber::STATUSES.each do |status| %>
19
+ <td class="shiplog-admin-counts__number"><%= counts[status] %></td>
20
+ <% end %>
21
+ </tr>
22
+ <% end %>
23
+ <tr>
24
+ <th scope="row"><%= t("shiplog.admin.newsletter.total") %></th>
25
+ <% Shiplog::NewsletterSubscriber::STATUSES.each do |status| %>
26
+ <td class="shiplog-admin-counts__number"><%= @counts.values.sum { |counts| counts[status] } %></td>
27
+ <% end %>
28
+ </tr>
29
+ </tbody>
30
+ </table>
31
+
32
+ <div class="shiplog-admin-actions">
33
+ <%= form_with url: newsletter_subscribers_path, method: :get, class: "shiplog-admin-inline-form" do |form| %>
34
+ <%= form.label :q, t("shiplog.admin.newsletter.search"), class: "shiplog-admin__visually-hidden" %>
35
+ <%= form.search_field :q, value: @query, placeholder: t("shiplog.admin.newsletter.search_placeholder") %>
36
+ <%= form.submit t("shiplog.admin.newsletter.search"), class: "shiplog-button" %>
37
+ <% if @query.present? %>
38
+ <%= link_to t("shiplog.admin.newsletter.clear"), newsletter_subscribers_path, class: "shiplog-button" %>
39
+ <% end %>
40
+ <% end %>
41
+ <p><%= link_to t("shiplog.admin.newsletter.export"), newsletter_subscribers_path(format: :csv, q: @query.presence), class: "shiplog-button" %></p>
42
+ </div>
43
+
44
+ <% if @subscribers.empty? %>
45
+ <p class="shiplog-admin__empty"><%= @query.present? ? t("shiplog.admin.newsletter.no_match", query: @query) : t("shiplog.admin.newsletter.empty") %></p>
46
+ <% else %>
47
+ <table class="shiplog-admin-table">
48
+ <thead>
49
+ <tr>
50
+ <th scope="col"><%= t("shiplog.admin.newsletter.columns.email") %></th>
51
+ <th scope="col"><%= t("shiplog.admin.newsletter.columns.locale") %></th>
52
+ <th scope="col"><%= t("shiplog.admin.newsletter.columns.status") %></th>
53
+ <th scope="col"><%= t("shiplog.admin.newsletter.columns.source") %></th>
54
+ <th scope="col"><%= t("shiplog.admin.newsletter.columns.subscribed_at") %></th>
55
+ <th scope="col"><%= t("shiplog.admin.entries.columns.actions") %></th>
56
+ </tr>
57
+ </thead>
58
+ <tbody>
59
+ <% @subscribers.each do |subscriber| %>
60
+ <tr id="shiplog-subscriber-<%= subscriber.id %>">
61
+ <td><%= subscriber.email %></td>
62
+ <td><%= subscriber.locale %></td>
63
+ <td><%= t("shiplog.admin.newsletter.statuses.#{subscriber.status}") %></td>
64
+ <td><%= subscriber.source %></td>
65
+ <td><%= l(subscriber.created_at, format: :short) %></td>
66
+ <td class="shiplog-admin-table__actions">
67
+ <%= button_to t("shiplog.admin.newsletter.remove"), newsletter_subscriber_path(subscriber, q: @query.presence), method: :delete, class: "shiplog-button" %>
68
+ </td>
69
+ </tr>
70
+ <% end %>
71
+ </tbody>
72
+ </table>
73
+ <% end %>
74
+
75
+ <nav class="shiplog-admin-pagination" aria-label="<%= t("shiplog.admin.entries.pagination") %>">
76
+ <% if @page > 1 %>
77
+ <%= link_to t("shiplog.admin.entries.newer"), newsletter_subscribers_path(q: @query.presence, page: (@page - 1 if @page > 2)), class: "shiplog-admin-pagination__prev" %>
78
+ <% end %>
79
+ <% if @more %>
80
+ <%= link_to t("shiplog.admin.entries.older"), newsletter_subscribers_path(q: @query.presence, page: @page + 1), class: "shiplog-admin-pagination__next" %>
81
+ <% end %>
82
+ </nav>
83
+ <% end %>
@@ -0,0 +1,16 @@
1
+ <%# A month's announcement: its title and Markdown body (Shiplog::Markdown, which escapes everything it does not build) %>
2
+ <% content_for :title, t("shiplog.permalink.title", title: @entry.display_title, changelog: t("shiplog.changelog.title", default: "Changelog"), default: "%{title} · %{changelog}") %>
3
+ <% shiplog_head_page :permalink, title: @entry.display_title, entry: @entry, url: shiplog_entry_url(@entry), json_ld: shiplog_article_json_ld(@entry) %>
4
+ <%= render layout: "shiplog/page", locals: { name: "announcement" } do %>
5
+ <%= render "shiplog/page_header", title: t("shiplog.changelog.title", default: "Changelog"), lede: nil %>
6
+ <article class="<%= shiplog_class(:announcement) %>">
7
+ <span class="<%= shiplog_class(:entry_category) %>"><%= t("shiplog.categories.announcement", default: "Monthly update") %></span>
8
+ <time class="<%= shiplog_class(:entry_date) %>" datetime="<%= @entry.released_on.beginning_of_month.strftime("%Y-%m") %>"><%= l(@entry.released_on, format: "%B %Y") %></time>
9
+ <h2 class="<%= shiplog_class(:announcement_title) %>"><%= @entry.display_title %></h2>
10
+ <div class="<%= shiplog_class(:announcement_body) %>"><%= Shiplog::Markdown.to_html(@entry.display_body, heading_offset: 1) %></div>
11
+ </article>
12
+ <% if Shiplog.newsletter? %>
13
+ <%= render "shiplog/newsletter/form", source: request.path %>
14
+ <% end %>
15
+ <%= link_to t("shiplog.announcements.back", default: "← Back to the changelog"), changelog_path(anchor: shiplog_month_anchor(@entry.released_on)), class: shiplog_class(:permalink_back) %>
16
+ <% end %>
@@ -1,8 +1,15 @@
1
- <%# locals: (month:, entries:) %>
2
- <%# one month of the changelog (month is its first day) and its entries, newest first %>
1
+ <%# locals: (month:, entries:, announcement: nil) %>
2
+ <%# one month of the changelog (month is its first day) and its entries, newest first; a link to the month's
3
+ announcement under the heading when it has one (see shiplog_announcement_href) %>
3
4
  <% heading_id = "shiplog-month-#{month.strftime("%Y-%m")}" %>
5
+ <% month_name = l(month, format: "%B %Y") %>
4
6
  <section class="<%= shiplog_class(:month) %>" aria-labelledby="<%= heading_id %>">
5
- <h2 class="<%= shiplog_class(:month_title) %>" id="<%= heading_id %>"><%= l(month, format: "%B %Y") %></h2>
7
+ <h2 class="<%= shiplog_class(:month_title) %>" id="<%= heading_id %>"><%= month_name %></h2>
8
+ <% if announcement && (href = shiplog_announcement_href(announcement)) %>
9
+ <p class="<%= shiplog_class(:month_announcement) %>">
10
+ <%= link_to t("shiplog.announcements.read", month: month_name, default: "Read the %{month} summary"), href, class: shiplog_class(:month_announcement_link) %>
11
+ </p>
12
+ <% end %>
6
13
  <ul class="<%= shiplog_class(:entries) %>">
7
14
  <%= render partial: "shiplog/changelogs/entry", collection: entries, as: :entry %>
8
15
  </ul>
@@ -17,13 +17,16 @@
17
17
  <% else %>
18
18
  <div class="<%= shiplog_class(:months) %>">
19
19
  <% @months.each do |month, entries| %>
20
- <%= render "shiplog/changelogs/month", month:, entries: %>
20
+ <%= render "shiplog/changelogs/month", month:, entries:, announcement: @announcements[month] %>
21
21
  <% end %>
22
22
  </div>
23
23
  <% end %>
24
24
  <%= render "shiplog/pagination",
25
25
  newer_path: (changelog_path(area: @area&.slug, page: (@page - 1 if @page > 2), q: @query) if @page > 1),
26
26
  older_path: (changelog_path(area: @area&.slug, page: @page + 1, q: @query) if @older) %>
27
+ <% if Shiplog.newsletter? %>
28
+ <%= render "shiplog/newsletter/form", source: request.path %>
29
+ <% end %>
27
30
  <p class="<%= shiplog_class(:footnote) %>">
28
31
  <%= link_to t("shiplog.feed.subscribe", default: "Subscribe (Atom)"), changelog_feed_path, class: shiplog_class(:footnote_link, :feed) %>
29
32
  </p>
@@ -0,0 +1,25 @@
1
+ <%# locals: (source: nil, email: nil, error: nil) %>
2
+ <%# The subscribe form: one email field, a honeypot (bots fill it, people never see it), a plain POST that works
3
+ without JavaScript. No CSRF token on purpose: the pages carrying it are cached with ETags (see
4
+ Shiplog::Newsletter::SubscriptionsController). `source` is recorded as the page the visitor subscribed on. %>
5
+ <% if Shiplog.newsletter? %>
6
+ <section class="<%= shiplog_class(:newsletter) %>" aria-labelledby="shiplog-newsletter-title">
7
+ <h2 class="<%= shiplog_class(:newsletter_title) %>" id="shiplog-newsletter-title"><%= t("shiplog.newsletter.form.title", default: "Get the monthly summary by email") %></h2>
8
+ <p class="<%= shiplog_class(:newsletter_hint) %>"><%= t("shiplog.newsletter.form.hint", default: "One email a month, no more. Unsubscribe any time.") %></p>
9
+ <% if error %>
10
+ <p class="<%= shiplog_class(:newsletter_notice, :error) %>" role="alert"><%= error %></p>
11
+ <% end %>
12
+ <%= form_with url: shiplog_newsletter_subscription_path, method: :post, authenticity_token: false, class: shiplog_class(:newsletter_form), data: { turbo: false } do |form| %>
13
+ <%= form.hidden_field :source, value: shiplog_newsletter_source(source) %>
14
+ <%# the honeypot: a name no browser autofills, out of the tab order and of autofill %>
15
+ <div class="shiplog-newsletter__trap" aria-hidden="true">
16
+ <%= form.label Shiplog::Newsletter::SubscriptionsController::HONEYPOT, t("shiplog.newsletter.form.trap_label", default: "Leave this field empty"), tabindex: -1 %>
17
+ <%= form.text_field Shiplog::Newsletter::SubscriptionsController::HONEYPOT, tabindex: -1, autocomplete: "off", "data-1p-ignore": "", "data-lpignore": "true" %>
18
+ </div>
19
+ <%= form.label :email, t("shiplog.newsletter.form.email_label", default: "Email address"), class: shiplog_class(:newsletter_label) %>
20
+ <%= form.email_field :email, value: email, required: true, autocomplete: "email", maxlength: Shiplog::NewsletterSubscriber::EMAIL_MAX_LENGTH,
21
+ placeholder: t("shiplog.newsletter.form.email_placeholder", default: "you@example.com"), class: shiplog_class(:newsletter_input) %>
22
+ <%= form.button t("shiplog.newsletter.form.submit", default: "Subscribe"), type: "submit", class: shiplog_class(:newsletter_button) %>
23
+ <% end %>
24
+ </section>
25
+ <% end %>
@@ -0,0 +1,14 @@
1
+ <%# locals: (heading:) %>
2
+ <%# rendered with `render layout:`; a newsletter page: the changelog's page chrome, a heading and the block's text %>
3
+ <% content_for :title, t("shiplog.newsletter.title", default: "Newsletter") %>
4
+ <%= render layout: "shiplog/page", locals: { name: "newsletter" } do %>
5
+ <%= render "shiplog/page_header", title: t("shiplog.newsletter.title", default: "Newsletter"), lede: nil %>
6
+ <section class="<%= shiplog_class(:newsletter, :page) %>" aria-labelledby="shiplog-newsletter-heading">
7
+ <h2 class="<%= shiplog_class(:newsletter_title) %>" id="shiplog-newsletter-heading"><%= heading %></h2>
8
+ <% if notice.present? %><p class="<%= shiplog_class(:newsletter_notice) %>" role="status"><%= notice %></p><% end %>
9
+ <%= yield %>
10
+ </section>
11
+ <p class="<%= shiplog_class(:footnote) %>">
12
+ <%= link_to t("shiplog.newsletter.back", default: "← Back to the changelog"), changelog_path, class: shiplog_class(:footnote_link) %>
13
+ </p>
14
+ <% end %>
@@ -0,0 +1,5 @@
1
+ <%# Rendered by the confirming POST: subscribed, with the way to the preferences page %>
2
+ <%= render layout: "shiplog/newsletter/page", locals: { heading: t("shiplog.newsletter.confirm.done_heading", default: "You're subscribed") } do %>
3
+ <p><%= t("shiplog.newsletter.confirm.done_text", email: @subscriber.email, default: "The monthly summary will arrive at %{email}. You can change the language or unsubscribe any time from your preferences.") %></p>
4
+ <p><%= link_to t("shiplog.newsletter.preferences.heading", default: "Your subscription"), newsletter_preferences_path(@subscriber.token), class: shiplog_class(:footnote_link) %></p>
5
+ <% end %>
@@ -0,0 +1,5 @@
1
+ <%# The confirmation link's page: a button that POSTs (a GET confirms nothing, link scanners prefetch) %>
2
+ <%= render layout: "shiplog/newsletter/page", locals: { heading: t("shiplog.newsletter.confirm.heading", default: "Confirm your subscription") } do %>
3
+ <p><%= t("shiplog.newsletter.confirm.text", email: @subscriber.email, default: "Click the button to receive the monthly summary at %{email}.") %></p>
4
+ <%= button_to t("shiplog.newsletter.confirm.button", default: "Confirm"), newsletter_confirmation_path(params[:token]), method: :post, class: shiplog_class(:newsletter_button), form: { data: { turbo: false } } %>
5
+ <% end %>
@@ -0,0 +1,4 @@
1
+ <%# A link that does not verify: tampered, expired, or its subscriber was deleted (status 404) %>
2
+ <%= render layout: "shiplog/newsletter/page", locals: { heading: t("shiplog.newsletter.invalid.heading", default: "This link is not valid") } do %>
3
+ <p><%= t("shiplog.newsletter.invalid.text", default: "It may have expired or already been used. Subscribe again from the changelog to get a new one.") %></p>
4
+ <% end %>
@@ -0,0 +1,4 @@
1
+ <%# Rendered by "Delete my data": the row is gone %>
2
+ <%= render layout: "shiplog/newsletter/page", locals: { heading: t("shiplog.newsletter.preferences.deleted_heading", default: "Your data was deleted") } do %>
3
+ <p><%= t("shiplog.newsletter.preferences.deleted_text", default: "Your email address is no longer stored. You can subscribe again from the changelog at any time.") %></p>
4
+ <% end %>
@@ -0,0 +1,23 @@
1
+ <%# A subscriber's preferences (reached through the footer's token): language, unsubscribe or resubscribe, delete %>
2
+ <% path = newsletter_preferences_path(@subscriber.token) %>
3
+ <%= render layout: "shiplog/newsletter/page", locals: { heading: t("shiplog.newsletter.preferences.heading", default: "Your subscription") } do %>
4
+ <p>
5
+ <%= t("shiplog.newsletter.preferences.email", default: "Email") %>: <strong><%= @subscriber.email %></strong><br>
6
+ <%= t("shiplog.newsletter.preferences.status", default: "Status") %>: <%= t("shiplog.newsletter.preferences.statuses.#{@subscriber.status}", default: @subscriber.status) %>
7
+ </p>
8
+ <%= form_with url: path, method: :patch, class: shiplog_class(:newsletter_form), data: { turbo: false } do |form| %>
9
+ <%= form.label :locale, t("shiplog.newsletter.preferences.language", default: "Language"), class: shiplog_class(:newsletter_label) %>
10
+ <%= form.select :locale, Shiplog::NewsletterSubscriber.allowed_locales.map { |locale| [ Shiplog::Translator.language_name(locale), locale ] },
11
+ { selected: @subscriber.locale }, class: shiplog_class(:newsletter_input) %>
12
+ <%= form.button t("shiplog.newsletter.preferences.save", default: "Save"), type: "submit", class: shiplog_class(:newsletter_button) %>
13
+ <% end %>
14
+ <div class="shiplog-newsletter__actions">
15
+ <% if @subscriber.unsubscribed? %>
16
+ <%= button_to t("shiplog.newsletter.preferences.resubscribe", default: "Resubscribe"), path, method: :patch, params: { subscribed: "1" }, class: shiplog_class(:newsletter_button), form: { data: { turbo: false } } %>
17
+ <% else %>
18
+ <%= button_to t("shiplog.newsletter.preferences.unsubscribe", default: "Unsubscribe"), path, method: :patch, params: { subscribed: "0" }, class: shiplog_class(:newsletter_button), form: { data: { turbo: false } } %>
19
+ <% end %>
20
+ <%= button_to t("shiplog.newsletter.preferences.delete", default: "Delete my data"), path, method: :delete, class: shiplog_class(:newsletter_button, :secondary), form: { data: { turbo: false } } %>
21
+ </div>
22
+ <p class="<%= shiplog_class(:newsletter_hint) %>"><%= t("shiplog.newsletter.preferences.delete_help", default: "Deleting your data removes your address from our records entirely, including the record that you unsubscribed.") %></p>
23
+ <% end %>
@@ -0,0 +1,4 @@
1
+ <%# Too many subscribe attempts from one address or one IP (status 429) %>
2
+ <%= render layout: "shiplog/newsletter/page", locals: { heading: t("shiplog.newsletter.rate_limited.heading", default: "Too many attempts") } do %>
3
+ <p><%= t("shiplog.newsletter.rate_limited.text", default: "Please try again in an hour.") %></p>
4
+ <% end %>
@@ -0,0 +1,16 @@
1
+ <%# "Check your inbox" after the form, the same for every address; with @error (an address that is not an email),
2
+ the form again (status 422) %>
3
+ <% if @error %>
4
+ <% content_for :title, t("shiplog.newsletter.title", default: "Newsletter") %>
5
+ <%= render layout: "shiplog/page", locals: { name: "newsletter" } do %>
6
+ <%= render "shiplog/page_header", title: t("shiplog.newsletter.title", default: "Newsletter"), lede: nil %>
7
+ <%= render "shiplog/newsletter/form", email: @email, error: @error, source: params[:source] %>
8
+ <p class="<%= shiplog_class(:footnote) %>">
9
+ <%= link_to t("shiplog.newsletter.back", default: "← Back to the changelog"), changelog_path, class: shiplog_class(:footnote_link) %>
10
+ </p>
11
+ <% end %>
12
+ <% else %>
13
+ <%= render layout: "shiplog/newsletter/page", locals: { heading: t("shiplog.newsletter.sent.heading", default: "Check your inbox") } do %>
14
+ <p><%= t("shiplog.newsletter.sent.text", default: "We sent you a link to confirm your subscription. Open it to start receiving the monthly summary. If nothing arrives within a few minutes, look in your spam folder.") %></p>
15
+ <% end %>
16
+ <% end %>
@@ -0,0 +1,4 @@
1
+ <%# Rendered by the unsubscribing POST (the button, or a mail provider's one-click request) %>
2
+ <%= render layout: "shiplog/newsletter/page", locals: { heading: t("shiplog.newsletter.unsubscribe.done_heading", default: "You're unsubscribed") } do %>
3
+ <p><%= t("shiplog.newsletter.unsubscribe.done_text", email: @email, default: "%{email} will not receive the monthly summary anymore.") %></p>
4
+ <% end %>
@@ -0,0 +1,5 @@
1
+ <%# The unsubscribe link's page: a button that POSTs (a GET unsubscribes nobody, link scanners prefetch) %>
2
+ <%= render layout: "shiplog/newsletter/page", locals: { heading: t("shiplog.newsletter.unsubscribe.heading", default: "Unsubscribe") } do %>
3
+ <p><%= t("shiplog.newsletter.unsubscribe.text", email: @email, default: "Stop receiving the monthly summary at %{email}?") %></p>
4
+ <%= button_to t("shiplog.newsletter.unsubscribe.button", default: "Unsubscribe"), newsletter_unsubscription_path(params[:token]), method: :post, class: shiplog_class(:newsletter_button), form: { data: { turbo: false } } %>
5
+ <% end %>
@@ -0,0 +1,113 @@
1
+ <%# The newsletter's HTML part: one 600px table column (fluid below that), every style inline (email clients drop
2
+ stylesheets), absolute image URLs with a width and an alt text, light colours that hold up when a client inverts
3
+ them for dark mode. @content is a Shiplog::Newsletter::Content: title, month, intro_html / highlights / rest_html
4
+ when the split rule applies (see Content), else the whole body as html; @unsubscribe_url and @preferences_url are
5
+ the recipient's own links. Copy this file into your app to change the design; keep the unsubscribe and preferences
6
+ links and the postal address (the List-Unsubscribe headers are set by the mailer). %>
7
+ <% palette = shiplog_mail_palette %>
8
+ <% font = shiplog_mail_font %>
9
+ <% accent = @content.accent %>
10
+ <% split = @content.highlights_shown? %>
11
+ <!DOCTYPE html>
12
+ <html lang="<%= I18n.locale %>">
13
+ <head>
14
+ <meta charset="utf-8">
15
+ <meta name="viewport" content="width=device-width, initial-scale=1">
16
+ <meta name="color-scheme" content="light dark">
17
+ <meta name="supported-color-schemes" content="light dark">
18
+ <title><%= @content.title %></title>
19
+ </head>
20
+ <body style="margin:0;padding:0;background-color:<%= palette[:page] %>;color:<%= palette[:text] %>;font-family:<%= font %>;">
21
+ <div style="display:none;max-height:0;overflow:hidden;font-size:1px;line-height:1px;color:<%= palette[:page] %>;"><%= @content.preheader %></div>
22
+ <table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="background-color:<%= palette[:page] %>;">
23
+ <tr>
24
+ <td align="center" style="padding:24px 12px;">
25
+ <table role="presentation" width="600" cellpadding="0" cellspacing="0" border="0" style="width:100%;max-width:600px;">
26
+ <tr>
27
+ <td style="padding:0 4px 14px;font-family:<%= font %>;">
28
+ <table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">
29
+ <tr>
30
+ <td style="font-family:<%= font %>;font-size:16px;font-weight:700;color:<%= palette[:text] %>;vertical-align:middle;">
31
+ <% if @content.logo_url %>
32
+ <%= image_tag @content.logo_url, alt: @content.product_name, width: 140, style: "display:block;width:140px;max-width:140px;height:auto;border:0;" %>
33
+ <% else %>
34
+ <%= @content.product_name %>
35
+ <% end %>
36
+ </td>
37
+ <% if @content.view_url %>
38
+ <td align="right" style="font-family:<%= font %>;font-size:12px;line-height:1.5;color:<%= palette[:muted] %>;vertical-align:middle;white-space:nowrap;">
39
+ <%= link_to t("shiplog.newsletter.mail.view_in_browser"), @content.view_url, style: "color:#{palette[:muted]};text-decoration:underline;" %>
40
+ </td>
41
+ <% end %>
42
+ </tr>
43
+ </table>
44
+ </td>
45
+ </tr>
46
+ <tr>
47
+ <td style="background-color:<%= palette[:card] %>;border:1px solid <%= palette[:border] %>;border-radius:10px;padding:36px 32px 28px;font-family:<%= font %>;">
48
+ <p style="margin:0 0 10px;font-family:<%= font %>;font-size:12px;line-height:1.5;letter-spacing:0.1em;text-transform:uppercase;font-weight:600;color:<%= accent %>;"><%= t("shiplog.categories.announcement", default: "Monthly update") %> · <%= @content.month %></p>
49
+ <h1 style="margin:0 0 16px;font-family:<%= font %>;font-size:32px;line-height:1.2;font-weight:800;letter-spacing:-0.01em;color:<%= palette[:text] %>;"><%= @content.title %></h1>
50
+ <% if split %>
51
+ <%= @content.intro_html %>
52
+ <% else %>
53
+ <%= @content.html %>
54
+ <%= shiplog_mail_button t("shiplog.newsletter.mail.changelog_button"), @content.changelog_url, accent %>
55
+ <% end %>
56
+ </td>
57
+ </tr>
58
+ <% if split %>
59
+ <% @content.highlights.each do |highlight| %>
60
+ <tr>
61
+ <td style="padding:16px 0 0;">
62
+ <table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="background-color:<%= palette[:card] %>;border:1px solid <%= palette[:border] %>;border-radius:10px;">
63
+ <% if highlight.image? %>
64
+ <tr>
65
+ <td style="padding:0;line-height:0;">
66
+ <%= shiplog_mail_screenshot(highlight) %>
67
+ </td>
68
+ </tr>
69
+ <% end %>
70
+ <tr>
71
+ <td style="padding:24px 28px 26px;font-family:<%= font %>;">
72
+ <h2 style="margin:0 0 8px;font-family:<%= font %>;font-size:21px;line-height:1.3;font-weight:700;color:<%= palette[:text] %>;"><%= link_to highlight.title, highlight.url, style: "color:#{palette[:text]};text-decoration:none;" %></h2>
73
+ <% if highlight.summary.present? %>
74
+ <p style="margin:0;font-family:<%= font %>;font-size:16px;line-height:1.6;color:<%= palette[:muted] %>;"><%= highlight.summary %></p>
75
+ <% end %>
76
+ <%= shiplog_mail_button t("shiplog.newsletter.mail.highlight_button"), highlight.url, accent, margin: "18px 0 0" %>
77
+ </td>
78
+ </tr>
79
+ </table>
80
+ </td>
81
+ </tr>
82
+ <% end %>
83
+ <tr>
84
+ <td style="padding:16px 0 0;">
85
+ <table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="background-color:<%= palette[:card] %>;border:1px solid <%= palette[:border] %>;border-radius:10px;">
86
+ <tr>
87
+ <td style="padding:28px 32px 28px;font-family:<%= font %>;">
88
+ <%= @content.rest_html %>
89
+ <table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="margin:24px 0 0;"><tr><td style="border-top:1px solid <%= palette[:border] %>;font-size:0;line-height:0;">&nbsp;</td></tr></table>
90
+ <%= shiplog_mail_button t("shiplog.newsletter.mail.changelog_button"), @content.changelog_url, accent, margin: "20px 0 0" %>
91
+ </td>
92
+ </tr>
93
+ </table>
94
+ </td>
95
+ </tr>
96
+ <% end %>
97
+ <tr>
98
+ <td style="padding:28px 12px 8px;font-family:<%= font %>;font-size:12px;line-height:1.7;color:<%= palette[:muted] %>;text-align:center;">
99
+ <%= t("shiplog.newsletter.mail.reason", product: @content.product_name) %><br>
100
+ <%= link_to t("shiplog.newsletter.mail.unsubscribe"), @unsubscribe_url, style: "color:#{palette[:muted]};text-decoration:underline;" %>
101
+ ·
102
+ <%= link_to t("shiplog.newsletter.mail.preferences"), @preferences_url, style: "color:#{palette[:muted]};text-decoration:underline;" %>
103
+ <% if @content.address.present? %>
104
+ <br><%= @content.address %>
105
+ <% end %>
106
+ </td>
107
+ </tr>
108
+ </table>
109
+ </td>
110
+ </tr>
111
+ </table>
112
+ </body>
113
+ </html>
@@ -0,0 +1,38 @@
1
+ <%# The newsletter's text part: title, month, the intro, the highlights ("Title — sentence", then the URL) and the rest
2
+ of the body when the split rule applies (see Shiplog::Newsletter::Content), else the whole body; then the links. %>
3
+ <%= @content.title %>
4
+ <%= t("shiplog.categories.announcement", default: "Monthly update") %> · <%= @content.month %>
5
+ <% if @content.view_url %>
6
+
7
+ <%= t("shiplog.newsletter.mail.view_in_browser") %>: <%= @content.view_url %>
8
+ <% end %>
9
+ <% if @content.highlights_shown? %>
10
+ <% if @content.intro_text.present? %>
11
+
12
+ <%= @content.intro_text %>
13
+ <% end %>
14
+ <% @content.highlights.each do |highlight| %>
15
+
16
+ <%= highlight.title %><% if highlight.summary.present? %> — <%= highlight.summary %><% end %>
17
+ <%= highlight.url %>
18
+ <% end %>
19
+ <% if @content.rest_text.present? %>
20
+
21
+ <%= @content.rest_text %>
22
+ <% end %>
23
+ <% else %>
24
+
25
+ <%= @content.text %>
26
+ <% end %>
27
+ <% if @content.changelog_url %>
28
+
29
+ <%= t("shiplog.newsletter.mail.changelog_button") %>: <%= @content.changelog_url %>
30
+ <% end %>
31
+
32
+ --
33
+ <%= t("shiplog.newsletter.mail.reason", product: @content.product_name) %>
34
+ <%= t("shiplog.newsletter.mail.unsubscribe") %>: <%= @unsubscribe_url %>
35
+ <%= t("shiplog.newsletter.mail.preferences") %>: <%= @preferences_url %>
36
+ <% if @content.address.present? %>
37
+ <%= @content.address %>
38
+ <% end %>
@@ -0,0 +1,43 @@
1
+ <%# The double opt-in email: a button (and the link as text) the visitor opens to confirm; @subscriber, @confirmation_url
2
+ and @product_name. The link works for 7 days; a page with a button is what it opens (a POST confirms), so a
3
+ link scanner that prefetches it confirms nothing. %>
4
+ <% palette = shiplog_mail_palette %>
5
+ <% font = shiplog_mail_font %>
6
+ <% accent = Shiplog.config.newsletter_accent || "#0969da" %>
7
+ <!DOCTYPE html>
8
+ <html lang="<%= I18n.locale %>">
9
+ <head>
10
+ <meta charset="utf-8">
11
+ <meta name="viewport" content="width=device-width, initial-scale=1">
12
+ <meta name="color-scheme" content="light dark">
13
+ <meta name="supported-color-schemes" content="light dark">
14
+ <title><%= t("shiplog.newsletter.mail.confirm_subject", product: @product_name) %></title>
15
+ </head>
16
+ <body style="margin:0;padding:0;background-color:<%= palette[:page] %>;color:<%= palette[:text] %>;font-family:<%= font %>;">
17
+ <table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="background-color:<%= palette[:page] %>;">
18
+ <tr>
19
+ <td align="center" style="padding:24px 12px;">
20
+ <table role="presentation" width="600" cellpadding="0" cellspacing="0" border="0" style="width:100%;max-width:600px;">
21
+ <tr>
22
+ <td style="background-color:<%= palette[:card] %>;border-radius:8px;padding:32px;font-family:<%= font %>;">
23
+ <% if Shiplog.config.newsletter_logo_url %>
24
+ <%= image_tag Shiplog.config.newsletter_logo_url, alt: @product_name, width: 140, style: "display:block;width:140px;max-width:140px;height:auto;border:0;margin:0 0 24px;" %>
25
+ <% end %>
26
+ <h1 style="margin:0 0 16px;font-family:<%= font %>;font-size:22px;line-height:1.3;font-weight:700;color:<%= palette[:text] %>;"><%= t("shiplog.newsletter.mail.confirm_heading") %></h1>
27
+ <p style="margin:0 0 12px;font-family:<%= font %>;font-size:16px;line-height:1.6;color:<%= palette[:text] %>;"><%= t("shiplog.newsletter.mail.confirm_intro", product: @product_name, email: @subscriber.email) %></p>
28
+ <%= shiplog_mail_button t("shiplog.newsletter.mail.confirm_button"), @confirmation_url, accent %>
29
+ <p style="margin:24px 0 0;font-family:<%= font %>;font-size:13px;line-height:1.6;color:<%= palette[:muted] %>;"><%= t("shiplog.newsletter.mail.confirm_expiry") %></p>
30
+ <p style="margin:12px 0 0;font-family:<%= font %>;font-size:12px;line-height:1.6;color:<%= palette[:muted] %>;word-break:break-all;"><%= t("shiplog.newsletter.mail.confirm_link_text") %> <%= link_to @confirmation_url, @confirmation_url, style: "color:#{palette[:muted]};" %></p>
31
+ </td>
32
+ </tr>
33
+ <% if Shiplog.config.newsletter_address.present? %>
34
+ <tr>
35
+ <td style="padding:24px 12px;font-family:<%= font %>;font-size:12px;line-height:1.7;color:<%= palette[:muted] %>;text-align:center;"><%= Shiplog.config.newsletter_address %></td>
36
+ </tr>
37
+ <% end %>
38
+ </table>
39
+ </td>
40
+ </tr>
41
+ </table>
42
+ </body>
43
+ </html>
@@ -0,0 +1,12 @@
1
+ <%# The double opt-in email's text part. %>
2
+ <%= t("shiplog.newsletter.mail.confirm_heading") %>
3
+
4
+ <%= t("shiplog.newsletter.mail.confirm_intro", product: @product_name, email: @subscriber.email) %>
5
+
6
+ <%= t("shiplog.newsletter.mail.confirm_button") %>: <%= @confirmation_url %>
7
+
8
+ <%= t("shiplog.newsletter.mail.confirm_expiry") %>
9
+ <% if Shiplog.config.newsletter_address.present? %>
10
+
11
+ <%= Shiplog.config.newsletter_address %>
12
+ <% end %>