shiplog 0.3.1 → 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 (99) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +62 -4
  3. data/README.md +178 -7
  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 +10 -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/head_helper.rb +1 -1
  28. data/app/helpers/shiplog/newsletter_helper.rb +28 -0
  29. data/app/helpers/shiplog/newsletter_mailer_helper.rb +37 -0
  30. data/app/helpers/shiplog/permalink_helper.rb +24 -5
  31. data/app/helpers/shiplog/unseen_helper.rb +4 -0
  32. data/app/jobs/shiplog/announce_job.rb +30 -0
  33. data/app/jobs/shiplog/draft_announcement_job.rb +9 -0
  34. data/app/jobs/shiplog/purge_newsletter_job.rb +15 -0
  35. data/app/jobs/shiplog/send_newsletter_job.rb +64 -0
  36. data/app/jobs/shiplog/translate_entry_job.rb +2 -0
  37. data/app/mailers/shiplog/newsletter_mailer.rb +51 -0
  38. data/app/models/shiplog/announcements.rb +34 -0
  39. data/app/models/shiplog/area_detector.rb +2 -1
  40. data/app/models/shiplog/change_candidates.rb +4 -0
  41. data/app/models/shiplog/drafter.rb +4 -2
  42. data/app/models/shiplog/entry.rb +76 -6
  43. data/app/models/shiplog/markdown.rb +233 -0
  44. data/app/models/shiplog/newsletter/content.rb +172 -0
  45. data/app/models/shiplog/newsletter/csv.rb +32 -0
  46. data/app/models/shiplog/newsletter/recipients.rb +92 -0
  47. data/app/models/shiplog/newsletter/tokens.rb +77 -0
  48. data/app/models/shiplog/newsletter.rb +180 -0
  49. data/app/models/shiplog/newsletter_delivery.rb +83 -0
  50. data/app/models/shiplog/newsletter_subscriber.rb +158 -0
  51. data/app/models/shiplog/period.rb +129 -0
  52. data/app/models/shiplog/public_links.rb +47 -0
  53. data/app/models/shiplog/rewriter/announcement_prompt.rb +70 -0
  54. data/app/models/shiplog/rewriter.rb +23 -3
  55. data/app/models/shiplog/translator/prompt.rb +2 -2
  56. data/app/models/shiplog/translator.rb +1 -1
  57. data/app/views/shiplog/admin/_nav.html.erb +3 -0
  58. data/app/views/shiplog/admin/entries/_announcement.html.erb +52 -0
  59. data/app/views/shiplog/admin/entries/_badges.html.erb +1 -1
  60. data/app/views/shiplog/admin/entries/_newsletter.html.erb +42 -0
  61. data/app/views/shiplog/admin/entries/edit.html.erb +8 -2
  62. data/app/views/shiplog/admin/entries/index.html.erb +9 -0
  63. data/app/views/shiplog/admin/entries/newsletters/show.html.erb +46 -0
  64. data/app/views/shiplog/admin/newsletter_subscribers/index.html.erb +83 -0
  65. data/app/views/shiplog/changelog_entries/announcement.html.erb +16 -0
  66. data/app/views/shiplog/changelogs/_month.html.erb +10 -3
  67. data/app/views/shiplog/changelogs/show.html.erb +4 -1
  68. data/app/views/shiplog/newsletter/_form.html.erb +25 -0
  69. data/app/views/shiplog/newsletter/_page.html.erb +14 -0
  70. data/app/views/shiplog/newsletter/confirmations/create.html.erb +5 -0
  71. data/app/views/shiplog/newsletter/confirmations/show.html.erb +5 -0
  72. data/app/views/shiplog/newsletter/invalid.html.erb +4 -0
  73. data/app/views/shiplog/newsletter/preferences/destroy.html.erb +4 -0
  74. data/app/views/shiplog/newsletter/preferences/show.html.erb +23 -0
  75. data/app/views/shiplog/newsletter/rate_limited.html.erb +4 -0
  76. data/app/views/shiplog/newsletter/subscriptions/show.html.erb +16 -0
  77. data/app/views/shiplog/newsletter/unsubscriptions/create.html.erb +4 -0
  78. data/app/views/shiplog/newsletter/unsubscriptions/show.html.erb +5 -0
  79. data/app/views/shiplog/newsletter_mailer/announcement.html.erb +113 -0
  80. data/app/views/shiplog/newsletter_mailer/announcement.text.erb +38 -0
  81. data/app/views/shiplog/newsletter_mailer/confirmation.html.erb +43 -0
  82. data/app/views/shiplog/newsletter_mailer/confirmation.text.erb +12 -0
  83. data/config/admin_routes.rb +12 -0
  84. data/config/locales/en.yml +147 -0
  85. data/config/locales/es.yml +147 -0
  86. data/config/routes.rb +13 -0
  87. data/db/migrate/20261028000001_shiplog_0_4_0.rb +19 -0
  88. data/db/migrate/20261028000002_shiplog_0_4_0_newsletter.rb +44 -0
  89. data/lib/generators/shiplog/install/install_generator.rb +14 -1
  90. data/lib/generators/shiplog/install/templates/initializer.rb.tt +50 -0
  91. data/lib/shiplog/classes.rb +14 -1
  92. data/lib/shiplog/configuration.rb +135 -3
  93. data/lib/shiplog/engine.rb +2 -0
  94. data/{app/helpers → lib}/shiplog/head_tags_helper.rb +15 -5
  95. data/lib/shiplog/previews/shiplog/newsletter_mailer_preview.rb +77 -0
  96. data/lib/shiplog/version.rb +1 -1
  97. data/lib/shiplog.rb +33 -0
  98. data/lib/tasks/shiplog.rake +22 -0
  99. metadata +57 -2
@@ -0,0 +1,233 @@
1
+ module Shiplog
2
+ # A small, dependency-free Markdown subset for announcement bodies (written by an LLM, edited by an admin, shown
3
+ # on public pages, in the feed and in emails). XSS-safe by construction: every piece of source text is escaped
4
+ # before it is placed in a tag we build ourselves, and raw HTML is never passed through.
5
+ module Markdown
6
+ HEADING = /\A {0,3}(\#{1,6})[ \t]+(.*?)(?:[ \t]+\#+)?[ \t]*\z/
7
+ RULE = /\A {0,3}([-*_])(?:[ \t]*\1){2,}[ \t]*\z/
8
+ ITEM = /\A([ \t]*)([-*+]|\d+[.)])[ \t]+(.*)\z/
9
+ QUOTE = /\A {0,3}>[ \t]?(.*)\z/
10
+ LINK = /\G\[((?:\\.|[^\]\\])*)\]\(((?:[^()\\]|\\.|\([^()]*\))*)\)/
11
+ IMAGE = /\G!\[((?:\\.|[^\]\\])*)\]\(((?:[^()\\]|\\.|\([^()]*\))*)\)/
12
+ ESCAPABLE = %w[* _ [ ] ( ) ` \\ # ! -].freeze
13
+ LINK_URL = %r{\A(?:https?://|mailto:|#|/(?![/\\]))}i
14
+ IMAGE_URL = %r{\A(?:https?://|/(?![/\\]))}i
15
+ CONTROL_CHARS = /[[:cntrl:]]/
16
+
17
+ # Block-level HTML for +text+. Heading levels are shifted by +heading_offset+ (clamped to h1..h6). +styles+
18
+ # maps tag names to inline styles ({ "p" => "margin:0 0 12px;" }) for emails, where stylesheets are ignored.
19
+ def self.to_html(text, heading_offset: 0, styles: {})
20
+ html = blocks(text).map { |block| render_block_html(block, heading_offset, styles) }.join("\n")
21
+ html.html_safe # every fragment was escaped or built by us
22
+ end
23
+
24
+ # Plain text (no HTML escaping) for emails and meta descriptions; blocks are separated by a blank line.
25
+ def self.to_text(text)
26
+ blocks(text).filter_map { |block| render_block_text(block) }.join("\n\n")
27
+ end
28
+
29
+ # Splits the source into [:heading, level, text], [:paragraph, text], [:quote, text], [:list, ordered, items]
30
+ # and [:rule] blocks.
31
+ def self.blocks(text)
32
+ lines = text.to_s.gsub(/\r\n?/, "\n").split("\n")
33
+ result = []
34
+ until lines.empty?
35
+ line = lines.first
36
+ if line.strip.empty?
37
+ lines.shift
38
+ elsif (match = line.match(HEADING))
39
+ lines.shift
40
+ result << [ :heading, match[1].length, match[2] ]
41
+ elsif line.match?(RULE)
42
+ lines.shift
43
+ result << [ :rule ]
44
+ elsif line.match?(ITEM)
45
+ result << take_list(lines)
46
+ elsif line.match?(QUOTE)
47
+ result << [ :quote, take_while(lines) { |l| l.match(QUOTE)&.[](1) }.join("\n") ]
48
+ else
49
+ result << [ :paragraph, take_paragraph(lines).join("\n").strip ]
50
+ end
51
+ end
52
+ result
53
+ end
54
+ private_class_method :blocks
55
+
56
+ def self.block_start?(line)
57
+ line.match?(HEADING) || line.match?(RULE) || line.match?(ITEM) || line.match?(QUOTE)
58
+ end
59
+ private_class_method :block_start?
60
+
61
+ # Shifts consecutive lines for which the block returns a (non-nil) value, and returns those values.
62
+ def self.take_while(lines)
63
+ taken = []
64
+ while lines.any? && (value = yield(lines.first))
65
+ taken << value
66
+ lines.shift
67
+ end
68
+ taken
69
+ end
70
+ private_class_method :take_while
71
+
72
+ def self.take_paragraph(lines)
73
+ [ lines.shift ] + take_while(lines) { |l| l unless l.strip.empty? || block_start?(l) }
74
+ end
75
+ private_class_method :take_paragraph
76
+
77
+ # Items of one list kind. Indented items continue the list, indented non-item lines continue the last item, and
78
+ # blank lines are skipped only when the next non-blank line is another item of the same kind.
79
+ def self.take_list(lines)
80
+ ordered = ordered_item?(lines.first)
81
+ items = []
82
+ until lines.empty?
83
+ line = lines.first
84
+ if line.strip.empty?
85
+ following = lines.find { |l| !l.strip.empty? }
86
+ break unless following&.match?(ITEM) && !following.match?(RULE) && ordered_item?(following) == ordered
87
+ lines.shift
88
+ elsif line.match?(RULE) || (line.match?(ITEM) && ordered_item?(line) != ordered)
89
+ break
90
+ elsif (match = line.match(ITEM))
91
+ items << match[3].strip
92
+ lines.shift
93
+ elsif line.match?(/\A[ \t]+\S/) && !line.match?(HEADING) && !line.match?(QUOTE)
94
+ items[-1] = "#{items.last}\n#{line.strip}"
95
+ lines.shift
96
+ else
97
+ break
98
+ end
99
+ end
100
+ [ :list, ordered, items ]
101
+ end
102
+ private_class_method :take_list
103
+
104
+ def self.ordered_item?(line) = line.match(ITEM)[2].match?(/\d/)
105
+ private_class_method :ordered_item?
106
+
107
+ # In HTML mode, +styles+ (a Hash, maybe empty) stands for the html flag of the inline renderers.
108
+ def self.render_block_html(block, heading_offset, styles)
109
+ case block.first
110
+ when :heading
111
+ level = (block[1] + heading_offset).clamp(1, 6)
112
+ "#{open("h#{level}", styles)}#{inline(block[2], styles)}</h#{level}>"
113
+ when :paragraph then "#{open("p", styles)}#{inline(block[1], styles)}</p>"
114
+ when :quote then "#{open("blockquote", styles)}#{open("p", styles)}#{inline(block[1], styles)}</p></blockquote>"
115
+ when :rule then open("hr", styles)
116
+ when :list
117
+ tag = block[1] ? "ol" : "ul"
118
+ items = block[2].map { |item| "#{open("li", styles)}#{inline(item, styles)}</li>" }.join("\n")
119
+ "#{open(tag, styles)}\n#{items}\n</#{tag}>"
120
+ end
121
+ end
122
+ private_class_method :render_block_html
123
+
124
+ # "<p>" or, with an inline style for the tag, "<p style="...">" (the style escaped like any attribute).
125
+ def self.open(tag, styles) = "<#{tag}#{style(tag, styles)}>"
126
+ private_class_method :open
127
+
128
+ def self.style(tag, styles)
129
+ value = styles[tag] if styles.is_a?(Hash)
130
+ value.present? ? %( style="#{escape(value, true)}") : ""
131
+ end
132
+ private_class_method :style
133
+
134
+ def self.render_block_text(block)
135
+ case block.first
136
+ when :heading then inline(block[2], false)
137
+ when :paragraph, :quote then inline(block[1], false)
138
+ when :list
139
+ block[2].each_with_index.map { |item, i| "#{block[1] ? "#{i + 1}." : "-"} #{inline(item, false)}" }.join("\n")
140
+ end
141
+ end
142
+ private_class_method :render_block_text
143
+
144
+ # Scans left to right; whatever is not a recognised construct is literal (and escaped in HTML mode).
145
+ def self.inline(source, html)
146
+ out = +""
147
+ i = 0
148
+ while i < source.length
149
+ output, i = inline_token(source, i, html)
150
+ out << output
151
+ end
152
+ out
153
+ end
154
+ private_class_method :inline
155
+
156
+ # Consumes one construct (or one literal character) at index +i+; returns its output and the next index.
157
+ def self.inline_token(source, i, html)
158
+ char = source[i]
159
+ if char == "\\" && ESCAPABLE.include?(source[i + 1])
160
+ [ escape(source[i + 1], html), i + 2 ]
161
+ elsif char == "`" && source[i + 1] == "`"
162
+ run = source[i..][/\A`+/]
163
+ [ run, i + run.length ] # a run of backticks (such as a ``` fence) is literal text
164
+ elsif char == "`" && (close = source.index("`", i + 1))
165
+ code = source[(i + 1)...close]
166
+ [ html ? "#{open("code", html)}#{escape(code, true)}</code>" : code, close + 1 ]
167
+ elsif char == "!" && (match = source.match(IMAGE, i))
168
+ [ image(match[1], match[2], html), i + match[0].length ]
169
+ elsif char == "[" && (match = source.match(LINK, i))
170
+ [ link(match[1], match[2], html), i + match[0].length ]
171
+ elsif char == "*" && (emphasis = emphasis(source, i, html))
172
+ emphasis
173
+ else
174
+ [ escape(char, html), i + 1 ]
175
+ end
176
+ end
177
+ private_class_method :inline_token
178
+
179
+ # **strong** or *em* when a closing marker exists and the content does not start with a space.
180
+ def self.emphasis(source, i, html)
181
+ marker = source[i, 2] == "**" ? "**" : "*"
182
+ start = i + marker.length
183
+ close = closing_marker(source, start, marker)
184
+ return if close.nil? || close == start || source[start].match?(/\s/)
185
+
186
+ inner = inline(source[start...close], html)
187
+ tag = marker == "**" ? "strong" : "em"
188
+ [ html ? "#{open(tag, html)}#{inner}</#{tag}>" : inner, close + marker.length ]
189
+ end
190
+ private_class_method :emphasis
191
+
192
+ # Index of the next unescaped +marker+ at or after +from+.
193
+ def self.closing_marker(source, from, marker)
194
+ i = from
195
+ while i < source.length
196
+ if source[i] == "\\"
197
+ i += 2
198
+ elsif source[i, marker.length] == marker
199
+ return source[i + marker.length] == "*" && marker == "**" ? i + 1 : i # "***": the last two close
200
+ else
201
+ i += 1
202
+ end
203
+ end
204
+ nil
205
+ end
206
+ private_class_method :closing_marker
207
+
208
+ # An anchor for allowed URLs, otherwise just the (escaped) text. Plain text shows "text (url)".
209
+ def self.link(label, url, html)
210
+ text = inline(label, html)
211
+ return text unless allowed_url?(url, LINK_URL)
212
+ return (text == url ? text : "#{text} (#{url})") unless html
213
+
214
+ %(<a href="#{escape(url, true)}"#{style("a", html)}>#{text}</a>)
215
+ end
216
+ private_class_method :link
217
+
218
+ # An img tag for allowed URLs, otherwise just the escaped alt text.
219
+ def self.image(alt, url, html)
220
+ text = inline(alt, false)
221
+ return escape(text, html) unless html && allowed_url?(url, IMAGE_URL)
222
+
223
+ %(<img src="#{escape(url, true)}" alt="#{escape(text, true)}" loading="lazy"#{style("img", html)}>)
224
+ end
225
+ private_class_method :image
226
+
227
+ def self.allowed_url?(url, pattern) = url.match?(pattern) && !url.match?(CONTROL_CHARS)
228
+ private_class_method :allowed_url?
229
+
230
+ def self.escape(text, html) = html ? ERB::Util.html_escape(text).to_str : text
231
+ private_class_method :escape
232
+ end
233
+ end
@@ -0,0 +1,172 @@
1
+ module Shiplog
2
+ module Newsletter
3
+ # What a newsletter says in one locale: the announcement's title, subject and body (the current translation,
4
+ # English when there is none), the Markdown rendered once as inline-styled HTML and as plain text, the
5
+ # month's feature highlights (cards with a screenshot, a sentence and a link), and the links.
6
+ # SendNewsletterJob builds one per locale and hands it to every message of that locale.
7
+ #
8
+ # The split rule: when there are highlights and the body has at least two "## " sections, the email shows
9
+ # the intro (the text before the first heading), the highlight cards in place of the body's FIRST section,
10
+ # then the rest of the body from the second section on ("Also improved"). Otherwise the body is shown whole.
11
+ # The split is made on the Markdown of the locale's text, so it works for translations whatever the headings
12
+ # say. Card texts come from the features' own approved (translated) entries, not from the body.
13
+ class Content
14
+ MAX_HIGHLIGHTS = 3
15
+ SUMMARY_LENGTH = 180
16
+ PREHEADER_LENGTH = 150
17
+ # A "## " heading at the start of a line (Markdown allows up to three leading spaces).
18
+ SECTION = /^ {0,3}\#{2}[ \t]/
19
+
20
+ # One highlight card: a feature's title, first sentence and permalink, and its first screenshot (absolute
21
+ # URL, alt text, size when the blob was analysed) or nil without one.
22
+ Highlight = Data.define(:title, :summary, :url, :image_url, :image_alt, :image_width, :image_height) do
23
+ def image? = image_url.present?
24
+ end
25
+
26
+ # The inline styles of the body's tags in the HTML part (email clients ignore stylesheets; see Markdown).
27
+ BODY_STYLES = {
28
+ "h1" => "margin:28px 0 10px;font-size:20px;line-height:1.3;font-weight:700;color:#1f2328;",
29
+ "h2" => "margin:28px 0 10px;font-size:18px;line-height:1.3;font-weight:700;color:#1f2328;",
30
+ "h3" => "margin:20px 0 6px;font-size:15px;line-height:1.4;font-weight:600;color:#1f2328;",
31
+ "h4" => "margin:16px 0 4px;font-size:15px;line-height:1.4;font-weight:600;color:#1f2328;",
32
+ "h5" => "margin:16px 0 4px;font-size:15px;line-height:1.4;font-weight:600;color:#1f2328;",
33
+ "h6" => "margin:16px 0 4px;font-size:15px;line-height:1.4;font-weight:600;color:#1f2328;",
34
+ "p" => "margin:0 0 12px;font-size:16px;line-height:1.6;color:#1f2328;",
35
+ "ul" => "margin:0 0 12px;padding:0 0 0 20px;",
36
+ "ol" => "margin:0 0 12px;padding:0 0 0 20px;",
37
+ "li" => "margin:0 0 4px;font-size:15px;line-height:1.55;color:#1f2328;",
38
+ "blockquote" => "margin:0 0 12px;padding:0 0 0 12px;border-left:3px solid #d1d9e0;color:#59636e;",
39
+ "hr" => "border:0;border-top:1px solid #d1d9e0;margin:20px 0;",
40
+ "code" => "font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:14px;background-color:#f6f8fa;padding:1px 4px;border-radius:3px;",
41
+ "img" => "display:block;max-width:100%;height:auto;border-radius:8px;margin:0 0 12px;"
42
+ }.freeze
43
+
44
+ attr_reader :entry, :locale
45
+
46
+ # `highlights:` replaces the ones built from the month's features (the mailer preview, with no rows).
47
+ def initialize(entry, locale, highlights: nil)
48
+ @entry = entry
49
+ @locale = Newsletter.locale_for(locale)
50
+ @highlights = highlights
51
+ end
52
+
53
+ def title = entry.display_title(locale).to_s
54
+
55
+ def subject = title
56
+
57
+ # The month the announcement covers ("October 2026"), in the locale when its I18n data has month names.
58
+ def month = Period.month_name(entry.released_on || Date.current, locale: Newsletter.i18n_locale(locale))
59
+
60
+ # The whole body as HTML, each tag inline-styled, links in the accent colour. Rendered once per Content.
61
+ def html = @html ||= render_html(markdown)
62
+
63
+ def text = @text ||= entry.to_text(locale)
64
+
65
+ # The body's first line, cut short, for the preheader (the preview line next to the subject).
66
+ def preheader = (intro_text.presence || text).lines.first.to_s.squish.truncate(PREHEADER_LENGTH)
67
+
68
+ # Up to MAX_HIGHLIGHTS of the month's approved features, in the changelog's order, each with a permalink
69
+ # (features without one are skipped). Built once per Content.
70
+ def highlights
71
+ @highlights ||= features.lazy.filter_map { |feature| highlight(feature) }.first(MAX_HIGHLIGHTS)
72
+ end
73
+
74
+ # Whether the email shows the intro, the cards and the rest instead of the whole body (the split rule).
75
+ def highlights_shown? = highlights.any? && sections.size >= 2
76
+
77
+ # The text before the body's first "## " heading, as HTML; nil unless the split rule applies.
78
+ def intro_html
79
+ @intro_html ||= render_html(intro_markdown) if highlights_shown?
80
+ end
81
+
82
+ # The body from its second "## " heading on, as HTML; nil unless the split rule applies. It opens a card, whose
83
+ # padding already spaces it, so the opening heading's top margin is dropped.
84
+ def rest_html
85
+ @rest_html ||= flush_top(render_html(rest_markdown)) if highlights_shown?
86
+ end
87
+
88
+ def intro_text
89
+ @intro_text ||= Markdown.to_text(intro_markdown) if highlights_shown?
90
+ end
91
+
92
+ def rest_text
93
+ @rest_text ||= Markdown.to_text(rest_markdown) if highlights_shown?
94
+ end
95
+
96
+ def view_url = @view_url ||= Newsletter.announcement_url(entry, locale)
97
+
98
+ def changelog_url = @changelog_url ||= Newsletter.changelog_url
99
+
100
+ def accent = Shiplog.config.newsletter_accent || "#0969da"
101
+
102
+ def logo_url = Shiplog.config.newsletter_logo_url
103
+
104
+ def address = Shiplog.config.newsletter_address
105
+
106
+ def product_name = Newsletter.product_name
107
+
108
+ # The absolute URL of an attachment's file through Active Storage's redirect route (the host's), or nil.
109
+ def self.image_url(attachment)
110
+ path = Rails.application.routes.url_helpers.rails_blob_path(attachment.blob, only_path: true)
111
+ Newsletter.absolute_url(path)
112
+ end
113
+
114
+ private
115
+
116
+ def markdown = @markdown ||= entry.to_markdown(locale).gsub(/\r\n?/, "\n")
117
+
118
+ # The body cut at its "## " headings: the text before the first one, then one String per section.
119
+ def sections = @sections ||= markdown.split(/(?=#{SECTION.source})/).tap { |parts| parts.shift unless parts.first.to_s.match?(SECTION) }
120
+
121
+ def intro_markdown = markdown.match?(SECTION) ? markdown.split(SECTION, 2).first.to_s : markdown
122
+
123
+ def rest_markdown = sections.drop(1).join
124
+
125
+ # The HTML with the top margin of a leading heading set to 0 (its style is BODY_STYLES', so it starts "margin:").
126
+ def flush_top(html)
127
+ html.sub(/\A(\s*<h[1-6] style=")margin:\d+px/) { "#{Regexp.last_match(1)}margin:0" }.html_safe # rubocop:disable Rails/OutputSafety
128
+ end
129
+
130
+ def render_html(source) = Markdown.to_html(source, styles: BODY_STYLES.merge("a" => "color:#{accent};text-decoration:underline;"))
131
+
132
+ # The month's approved features, images and this locale's translations loaded at once.
133
+ def features
134
+ period = entry.subject
135
+ return [] unless period.is_a?(Period)
136
+
137
+ features = period.changelog_entries.with_images_loaded.to_a.select(&:feature?)
138
+ Entry.preload_translations(features, locale:)
139
+ features
140
+ end
141
+
142
+ def highlight(feature)
143
+ url = Newsletter.absolute_url(PublicLinks.entry_url(feature))
144
+ return unless url
145
+
146
+ image = Shiplog.images? ? feature.ordered_images.first : nil
147
+ image_url = image && screenshot_url(feature, image)
148
+ metadata = image_url ? image.blob.metadata : {}
149
+ feature_title = feature.display_title(locale).to_s
150
+ Highlight.new(title: feature_title, summary: first_sentence(feature.display_body(locale)), url:, image_url:,
151
+ image_alt: (image_url ? feature.custom_image_alt(image) || feature_title : nil),
152
+ image_width: metadata[:width].to_i.positive? ? metadata[:width].to_i : nil,
153
+ image_height: metadata[:height].to_i.positive? ? metadata[:height].to_i : nil)
154
+ end
155
+
156
+ # A screenshot's absolute URL; nil, after a log line (no address in it), when the host's routes cannot give one.
157
+ def screenshot_url(feature, image)
158
+ self.class.image_url(image)
159
+ rescue StandardError => error
160
+ Rails.logger.warn("[shiplog] Newsletter: the screenshot of entry #{feature.id} is left out: #{error.class}")
161
+ nil
162
+ end
163
+
164
+ # The first sentence of a plain-text body (punctuation kept), cut at SUMMARY_LENGTH.
165
+ def first_sentence(body)
166
+ text = body.to_s.squish
167
+ match = text.match(Rewriter::SENTENCE_END)
168
+ (match ? text[0...match.end(0)] : text).truncate(SUMMARY_LENGTH)
169
+ end
170
+ end
171
+ end
172
+ end
@@ -0,0 +1,32 @@
1
+ module Shiplog
2
+ module Newsletter
3
+ # The admin's CSV export of subscribers, written by hand: the csv gem is no longer a default gem and Shiplog
4
+ # adds no dependency for a few lines. Every cell is quoted, and a cell that a spreadsheet would read as a
5
+ # formula (starting with =, +, -, @, a tab or a carriage return) gets a leading apostrophe, so an address
6
+ # like "=HYPERLINK(...)@evil.example" cannot run anything when the file is opened.
7
+ module Csv
8
+ HEADERS = %w[email locale status source subscribed_at confirmed_at unsubscribed_at].freeze
9
+ FORMULA = /\A[=+\-@\t\r]/
10
+
11
+ # The CSV text of a scope of subscribers, read in batches.
12
+ def self.generate(scope)
13
+ lines = [ line(HEADERS) ]
14
+ scope.find_each { |subscriber| lines << line(row(subscriber)) }
15
+ lines.join("\n") + "\n"
16
+ end
17
+
18
+ def self.row(subscriber)
19
+ [ subscriber.email, subscriber.locale, subscriber.status, subscriber.source,
20
+ *[ subscriber.created_at, subscriber.confirmed_at, subscriber.unsubscribed_at ].map { |time| time&.utc&.iso8601 } ]
21
+ end
22
+
23
+ def self.line(values) = values.map { |value| cell(value) }.join(",")
24
+
25
+ def self.cell(value)
26
+ text = value.to_s
27
+ text = "'#{text}" if FORMULA.match?(text)
28
+ %("#{text.gsub('"', '""')}")
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,92 @@
1
+ module Shiplog
2
+ module Newsletter
3
+ # The recipients of a newsletter, in batches: the host's users (config.newsletter_recipients, read in batches
4
+ # with find_each when the callable returns a relation) and then the confirmed subscribers. An address with an
5
+ # unsubscribed row (a suppression record, whatever the source of the address) is skipped, one query per
6
+ # batch; so is a duplicate: the first host recipient with an address wins, and a subscriber whose address a
7
+ # host user has is skipped. A host object without a usable email or id (or not responding to email, locale
8
+ # and id) is skipped with a warning that names no address, never a crash.
9
+ class Recipients
10
+ Recipient = Data.define(:key, :email, :locale, :source, :record) do
11
+ def host? = source == :host
12
+ def subscriber? = source == :subscriber
13
+ end
14
+
15
+ EMAIL = URI::MailTo::EMAIL_REGEXP
16
+
17
+ def initialize(batch_size: Shiplog.config.newsletter_batch_size)
18
+ @batch_size = batch_size
19
+ end
20
+
21
+ # Yields Arrays of at most batch_size Recipients (fewer once suppressed and duplicate addresses are dropped).
22
+ def each_batch(&block)
23
+ seen = Set.new
24
+ host_batches { |records| emit(records.filter_map { |record| host_recipient(record) }, seen, suppressed: true, &block) }
25
+ NewsletterSubscriber.confirmed.in_batches(of: @batch_size) do |subscribers|
26
+ emit(subscribers.map { |subscriber| subscriber_recipient(subscriber) }, seen, suppressed: false, &block)
27
+ end
28
+ end
29
+
30
+ # Yields each Recipient once, host users first.
31
+ def each(&block) = each_batch { |batch| batch.each(&block) }
32
+
33
+ # { "en" => 120, "es" => 30 }: how many recipients get each locale's email, after deduplication.
34
+ def counts_by_locale
35
+ counts = Hash.new(0)
36
+ each { |recipient| counts[recipient.locale] += 1 }
37
+ counts.sort_by { |locale, _| locale }.to_h
38
+ end
39
+
40
+ private
41
+
42
+ # Yields the candidates that are neither suppressed (checked in one query when `suppressed`; the confirmed
43
+ # subscribers' scope rules it out already) nor seen before.
44
+ def emit(candidates, seen, suppressed:)
45
+ skipped = suppressed ? suppressed_emails(candidates.map(&:email)) : Set.new
46
+ batch = candidates.select { |recipient| !skipped.include?(recipient.email) && seen.add?(recipient.email) }
47
+ yield batch if batch.any?
48
+ end
49
+
50
+ # The addresses among `emails` that unsubscribed (NewsletterSubscriber.suppress), lowercased.
51
+ def suppressed_emails(emails)
52
+ return Set.new if emails.empty?
53
+
54
+ NewsletterSubscriber.unsubscribed.where("lower(email) IN (?)", emails).pluck(:email).map(&:downcase).to_set
55
+ end
56
+
57
+ # The host's recipient objects, batch_size at a time (a relation is read with find_each).
58
+ def host_batches(&block)
59
+ callable = Shiplog.config.newsletter_recipients
60
+ return unless callable
61
+
62
+ records = callable.call
63
+ return if records.nil?
64
+
65
+ if records.respond_to?(:find_each)
66
+ records.find_each(batch_size: @batch_size).each_slice(@batch_size, &block)
67
+ else
68
+ records.each_slice(@batch_size, &block)
69
+ end
70
+ end
71
+
72
+ def host_recipient(record)
73
+ return skip("#{record.class.name}, which does not respond to email, locale and id") unless %i[email locale id].all? { |name| record.respond_to?(name) }
74
+ return skip("a #{record.class.name} without an id") if record.id.blank?
75
+
76
+ email = record.email.to_s.strip.downcase
77
+ return if email.empty? || !EMAIL.match?(email) || email.length > NewsletterSubscriber::EMAIL_MAX_LENGTH
78
+
79
+ Recipient.new(key: "#{record.class.name}/#{record.id}", email:, locale: Newsletter.locale_for(record.locale), source: :host, record:)
80
+ end
81
+
82
+ def subscriber_recipient(subscriber)
83
+ Recipient.new(key: subscriber.recipient_key, email: subscriber.email, locale: Newsletter.locale_for(subscriber.locale), source: :subscriber, record: subscriber)
84
+ end
85
+
86
+ def skip(what)
87
+ Rails.logger.warn("[shiplog] newsletter_recipients gave #{what}: skipped")
88
+ nil
89
+ end
90
+ end
91
+ end
92
+ end
@@ -0,0 +1,77 @@
1
+ module Shiplog
2
+ module Newsletter
3
+ # The tokens in newsletter links: encrypted and signed (AES-256-GCM through ActiveSupport::MessageEncryptor,
4
+ # URL-safe, JSON), keyed from the host's secret_key_base, each with its own purpose so one cannot stand in for
5
+ # another. Nothing in a token is readable without the key (no id, no address in clear in logs or caches); a
6
+ # tampered or expired token decrypts to nil.
7
+ # confirmation(subscriber) the double opt-in link: the row's single-use confirmation_nonce, valid
8
+ # CONFIRMATION_VALIDITY; the nonce is cleared when the link is used or the address
9
+ # unsubscribes, so an old link confirms nothing
10
+ # unsubscribe(email) the one-click and footer unsubscribe link: the address, encrypted, so the host's
11
+ # users need no row in Shiplog; it does not expire (footers live long)
12
+ # The preferences page uses the subscriber's stored token instead (NewsletterSubscriber#token), rotated after
13
+ # each change made there.
14
+ #
15
+ # Rotating secret_key_base: call `Shiplog::Newsletter::Tokens.rotate(old_secret_key_base)` in an initializer
16
+ # while old unsubscribe links must keep working (they are decrypted with the old key too).
17
+ module Tokens
18
+ CONFIRMATION_VALIDITY = 7.days
19
+ SALT = "shiplog newsletter".freeze
20
+ KEY_LENGTH = 32
21
+
22
+ # The confirmation link's token, or nil when the row has no pending confirmation.
23
+ def self.confirmation(subscriber)
24
+ nonce = subscriber.confirmation_nonce
25
+ encryptor.encrypt_and_sign({ "nonce" => nonce }, purpose: :confirm, expires_in: CONFIRMATION_VALIDITY) if nonce.present?
26
+ end
27
+
28
+ # The subscriber whose pending confirmation this token is, or nil (tampered, expired, used, or unsubscribed since).
29
+ def self.subscriber_for(token)
30
+ data = read(token, :confirm)
31
+ nonce = data["nonce"] if data.is_a?(Hash)
32
+ NewsletterSubscriber.find_by(confirmation_nonce: nonce) if nonce.is_a?(String) && nonce.present?
33
+ end
34
+
35
+ def self.unsubscribe(email) = encryptor.encrypt_and_sign({ "email" => email.to_s.strip.downcase }, purpose: :unsubscribe)
36
+
37
+ # The address of an unsubscribe token, or nil when it does not verify.
38
+ def self.email_for(token)
39
+ data = read(token, :unsubscribe)
40
+ data["email"].presence if data.is_a?(Hash) && data["email"].is_a?(String)
41
+ end
42
+
43
+ # Adds a previous secret_key_base whose tokens must still be accepted.
44
+ def self.rotate(secret_key_base)
45
+ previous_secrets << secret_key_base.to_s
46
+ @encryptor = nil
47
+ end
48
+
49
+ def self.reset_rotation!
50
+ previous_secrets.clear
51
+ @encryptor = nil
52
+ end
53
+
54
+ def self.previous_secrets = (@previous_secrets ||= [])
55
+
56
+ def self.encryptor
57
+ @encryptor ||= ActiveSupport::MessageEncryptor.new(Rails.application.key_generator.generate_key(SALT, KEY_LENGTH), url_safe: true, serializer: JSON).tap do |encryptor|
58
+ previous_secrets.each { |secret| encryptor.rotate(key_generator(secret).generate_key(SALT, KEY_LENGTH)) }
59
+ end
60
+ end
61
+
62
+ def self.key_generator(secret)
63
+ ActiveSupport::KeyGenerator.new(secret, iterations: 1000, hash_digest_class: ActiveSupport::KeyGenerator.hash_digest_class)
64
+ end
65
+ private_class_method :key_generator
66
+
67
+ def self.read(token, purpose)
68
+ return if token.blank?
69
+
70
+ encryptor.decrypt_and_verify(token.to_s, purpose:)
71
+ rescue ActiveSupport::MessageEncryptor::InvalidMessage, ActiveSupport::MessageVerifier::InvalidSignature, ArgumentError
72
+ nil
73
+ end
74
+ private_class_method :read
75
+ end
76
+ end
77
+ end