missive 0.0.1.pre → 0.0.2

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 (35) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +7 -0
  3. data/CONTRIBUTING.md +27 -0
  4. data/README.md +230 -16
  5. data/Rakefile +5 -4
  6. data/app/controllers/concerns/.keep +0 -0
  7. data/app/controllers/missive/application_controller.rb +4 -0
  8. data/app/controllers/missive/postmark/webhooks_controller.rb +92 -0
  9. data/app/helpers/missive/application_helper.rb +4 -0
  10. data/app/jobs/missive/application_job.rb +4 -0
  11. data/app/mailers/missive/application_mailer.rb +6 -0
  12. data/app/models/concerns/.keep +0 -0
  13. data/app/models/concerns/missive/suppressible.rb +26 -0
  14. data/app/models/concerns/missive/user.rb +10 -0
  15. data/app/models/concerns/missive/user_as_sender.rb +19 -0
  16. data/app/models/concerns/missive/user_as_subscriber.rb +23 -0
  17. data/app/models/missive/application_record.rb +5 -0
  18. data/app/models/missive/dispatch.rb +19 -0
  19. data/app/models/missive/list.rb +10 -0
  20. data/app/models/missive/message.rb +11 -0
  21. data/app/models/missive/sender.rb +10 -0
  22. data/app/models/missive/subscriber.rb +12 -0
  23. data/app/models/missive/subscription.rb +11 -0
  24. data/app/views/layouts/missive/application.html.erb +17 -0
  25. data/config/routes.rb +5 -0
  26. data/lefthook.yml +13 -0
  27. data/lib/generators/missive/install_generator.rb +33 -0
  28. data/lib/generators/missive/templates/migrations/install_missive.rb.erb +74 -0
  29. data/lib/missive/engine.rb +7 -0
  30. data/lib/missive/railtie.rb +4 -0
  31. data/lib/missive/stamp/api_client.rb +40 -0
  32. data/lib/missive/version.rb +1 -3
  33. data/lib/missive.rb +4 -4
  34. data/lib/tasks/missive_tasks.rake +4 -0
  35. metadata +87 -3
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a8f0fafdf0f75d8ed064f71eb41232a210e94a7cb628f7f1baf5ef143c21b9bf
4
- data.tar.gz: 1393cfff9b146e8b58e60e0be44241af4f5fddbf34a8058015187c487207ca73
3
+ metadata.gz: 63e8195762299f95bdc217efcf1a81dbd8be46ec029ea90b8b4db430af37eabd
4
+ data.tar.gz: 94532fa3c3cd42c9086b35385773c0d2f8483dad96690b0d78523512736aa86e
5
5
  SHA512:
6
- metadata.gz: bead54c48d4b4a1114a420a4ec18c02b0a7238521a27528564c27424fcb91469afbde5544a118040d1e70e06b02a96250c756c76ae509456ba79c69595b11c6c
7
- data.tar.gz: 530f6996cd21ac2dd8a3d792bd436b6ff748f6e32064f9849091bf2740403610aaa4f853ffbe1505ee8c72ec2aaa7b44ed1d22d2d993e94d7ee706007ee938ac
6
+ metadata.gz: d365c0b22c19489e7d61d46746bec34d58834c167066f02eb71cec287e855a7e820e1eb535e066c2f871cc948338bbbd5151efac7838ca037e2455b36c4dbe07
7
+ data.tar.gz: d7c9d72e65bbac0b683b6f5b69b888547f7ebc5245c35b6b9f582042ad8ef524b775acaed2aa47d17dd4a2be689315c02e41570913edebf7c213b5d0dc728f6f
data/CHANGELOG.md CHANGED
@@ -1,5 +1,12 @@
1
1
  ## [Unreleased]
2
2
 
3
+ - Add install generator
4
+ - Add Postmark Bulk API implementation
5
+
6
+ ## [0.0.1] - 2025-10-14
7
+
8
+ - First release: base models, database structure
9
+
3
10
  ## [0.0.1.pre] - 2025-09-30
4
11
 
5
12
  - First commit
data/CONTRIBUTING.md ADDED
@@ -0,0 +1,27 @@
1
+ # Contributing
2
+
3
+ Bug reports and pull requests are welcome on GitHub at https://github.com/Spone/missive. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [code of conduct](https://github.com/Spone/missive/blob/main/CODE_OF_CONDUCT.md).
4
+
5
+ ## Testing
6
+
7
+ Run the test suite with:
8
+
9
+ ```bash
10
+ bin/rails test
11
+ ```
12
+
13
+ ## Get started
14
+
15
+ - Fork the repository
16
+ - Run `bin/setup` to install dependencies
17
+ - Create your feature branch (`git checkout -b my-new-feature`)
18
+ - Make your changes
19
+ - Run the tests `bin/rails test`
20
+ - Commit your change, push the branch and create a pull request
21
+
22
+ ## Release
23
+
24
+ - Update the version number in `version.rb`
25
+ - Run `bundle exec rake release`, which will create a git tag for the version
26
+ - Push git commits and the created tag
27
+ - Push the `.gem` file to [rubygems.org](https://rubygems.org)
data/README.md CHANGED
@@ -1,38 +1,252 @@
1
1
  # Missive
2
2
 
3
- TODO: Delete this and the text below, and describe your gem
3
+ A lightweight Rails toolkit for building newsletter features. Missive provides the primitives for managing newsletters and subscribers, with [Postmark](https://postmarkapp.com/) handling delivery.
4
4
 
5
- Welcome to your new gem! In this directory, you'll find the files you need to be able to package up your Ruby library into a gem. Put your Ruby code in the file `lib/missive`. To experiment with that code, run `bin/console` for an interactive prompt.
5
+ ## Overview
6
6
 
7
- ## Installation
7
+ ### Features
8
8
 
9
- TODO: Replace `UPDATE_WITH_YOUR_GEM_NAME_IMMEDIATELY_AFTER_RELEASE_TO_RUBYGEMS_ORG` with your gem name right after releasing it to RubyGems.org. Please do not do it earlier due to security reasons. Alternatively, replace this section with instructions to install your gem from git if you don't plan to release to RubyGems.org.
9
+ - **Newsletter Management**: Create and organize newsletters within your Rails application
10
+ - **Postmark Integration**: Leverage Postmark's reliable email delivery service
11
+ - **Rails Native**: Designed to work seamlessly with Rails conventions and ActionMailer
12
+ - **Subscriber Management**: Handle your newsletter subscriber lists
10
13
 
11
- Install the gem and add to the application's Gemfile by executing:
14
+ ### Scope
15
+
16
+ #### Goals
17
+
18
+ - Rely on Postmark as much as possible, integrate with webhooks, send through Postmark API (not SMTP).
19
+ - Provide a way to compose messages that include content from the host app models.
20
+
21
+ #### Non-goals
22
+
23
+ - Integrate with other sending services.
24
+ - Send transactional emails or email sequences: Missive focuses on newsletters.
25
+ - Provide ready-made subscription forms: subscription management is the responsibility of the host app.
26
+ - Multi-tenancy: Missive is designed for a single Rails app and domain.
27
+
28
+ ## Concepts
29
+
30
+ ### Models
31
+
32
+ - `Sender` is an identity used to send messages, optionally associated with a `User` from the host app. It has a corresponding [Sender Signature](https://postmarkapp.com/developer/api/signatures-api) in Postmark.
33
+ - `Subscriber` is a person who have opted in to receiving emails or have been added manually, optionally associated with a `User` from the host app.
34
+ - `Subscription` is the relation between a subscriber and a list.
35
+ - `List` is a list of subscribers. It uses a specific [Message Stream](https://postmarkapp.com/developer/api/message-streams-api) to send messages through Postmark.
36
+ - `Message` is an email sent to subscribers of a given list.
37
+ - `Dispatch` is the relation between a subscriber and a message (ie. when a `Message` is sent, it's dispatched to all subscribers). It's called [Email](https://postmarkapp.com/developer/api/email-api) in Postmark.
38
+
39
+ ## Requirements
40
+
41
+ - Rails 8.0 or higher
42
+ - A Postmark account with API credentials
43
+
44
+ ### Dependencies
45
+
46
+ - Official [postmark](https://github.com/activecampaign/postmark-gem) gem
47
+ - [time_for_a_boolean](https://github.com/calebhearth/time_for_a_boolean) to back boolean concepts (`sent`, `delivered`, `open`, ...) with timestamps
48
+ - [rails-pattern_matching](https://github.com/kddnewton/rails-pattern_matching) to use pattern matching when processing incoming webhooks
49
+
50
+ ## Usage
51
+
52
+ ### Setup
12
53
 
13
54
  ```bash
14
- bundle add UPDATE_WITH_YOUR_GEM_NAME_IMMEDIATELY_AFTER_RELEASE_TO_RUBYGEMS_ORG
55
+ bundle add missive
15
56
  ```
16
57
 
17
- If bundler is not being used to manage dependencies, install the gem by executing:
58
+ Install the migrations:
18
59
 
19
60
  ```bash
20
- gem install UPDATE_WITH_YOUR_GEM_NAME_IMMEDIATELY_AFTER_RELEASE_TO_RUBYGEMS_ORG
61
+ rails generate missive:install
21
62
  ```
22
63
 
23
- ## Usage
64
+ ### Configuration
65
+
66
+ Missive uses the same configuration as `postmark-rails`. Please follow the [`postmark-rails` configuration instructions](https://github.com/ActiveCampaign/postmark-rails?tab=readme-ov-file#installation) to set up your Postmark API credentials.
67
+
68
+ ### Quick start
69
+
70
+ #### Connect the host app `User` model (optional)
71
+
72
+ The host app `User` can be associated to a `Missive::Subscriber` and/or a `Missive::Sender`, using the `Missive::User` concern.
73
+
74
+ ```rb
75
+ class User < ApplicationRecord
76
+ include Missive::User
77
+ end
78
+ ```
79
+
80
+ The concerns can also be included separately, which is useful if `User` needs to be implemented as a `Sender` or `Subscriber` only.
81
+
82
+ ```rb
83
+ class User < ApplicationRecord
84
+ include Missive::UserAsSender
85
+ include Missive::UserAsSubscriber
86
+ end
87
+ ```
88
+
89
+ This is equivalent to:
90
+
91
+ ```rb
92
+ class User < ApplicationRecord
93
+ # Missive::UserAsSender
94
+ has_one :sender # ...
95
+ has_many :sent_dispatches # ...
96
+ has_many :sent_lists # ...
97
+ has_many :sent_messages # ...
98
+
99
+ def init_sender(attributes = {});
100
+ # ...
101
+ end
102
+
103
+ # Missive::UserAsSubscriber
104
+ has_one :subscriber # ...
105
+ has_many :dispatches # ...
106
+ has_many :subscriptions # ...
107
+ has_many :subscribed_lists # ...
108
+ has_many :unsubscribed_lists # ...
109
+
110
+ def init_subscriber(attributes = {})
111
+ # ...
112
+ end
113
+ end
114
+ ```
115
+
116
+ #### Manage subscriptions
117
+
118
+ ```rb
119
+ user = User.first
120
+ list = Missive::List.first
24
121
 
25
- TODO: Write usage instructions here
122
+ # Make sure the User has an associated Missive::Subscriber:
123
+ # - if one exists with the same email, associate it
124
+ # - else create a new subscriber with the same email
125
+ user.init_subscriber
26
126
 
27
- ## Development
127
+ # List the subscriptions
128
+ user.subscriptions # returns a `Missive::Subscription` collection
28
129
 
29
- After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake test` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
130
+ # List the (un)subscribed lists
131
+ user.subscribed_lists # returns a `Missive::List` collection
132
+ user.unsubscribed_lists # returns a `Missive::List` collection
30
133
 
31
- To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org).
134
+ # Subscribe to an existing Missive::List
135
+ user.subscriber.subscriptions.create!(list:)
32
136
 
33
- ## Contributing
137
+ # Unsubscribe from the list
138
+ user.subscriptions.find_by(list:).suppress!(reason: :manual_suppression)
139
+ ```
140
+
141
+ #### Manage senders
142
+
143
+ ```rb
144
+ user = User.where(admin: true).first
145
+ list = Missive::List.first
146
+
147
+ # Make sure the User has an associated Missive::Sender:
148
+ # - if one exists with the same email, associate it
149
+ # - else create a new sender with the same email
150
+ # then assign them the provided name
151
+ user.init_sender(name: user.full_name)
152
+
153
+ # Make them the default sender for a list
154
+ user.sent_lists << list
155
+ ```
156
+
157
+ #### Manage lists
158
+
159
+ ```rb
160
+ # Create a new list
161
+ list = Missive::List.create!(name: "My newsletter")
162
+
163
+ # Choose a specific Message Stream to send messages for this list
164
+ list.update!(postmark_message_stream_id: "bulk")
165
+
166
+ # Get available lists
167
+ Missive::List.all
168
+
169
+ # Get list stats
170
+ list.subscriptions_count # how many people subscribe or unsubscribe to this list?
171
+ list.messages_count # how many messages have been created in this list?
172
+ ```
173
+
174
+ > [!WARNING]
175
+ > **Everything below is currently being developed and is not yet ready for use.**
176
+
177
+ #### Manage messages
178
+
179
+ ```rb
180
+ # Create a new message in a list
181
+ list.create_message!(subject: "Hello world!")
182
+
183
+ # TODO: add message content
34
184
 
35
- Bug reports and pull requests are welcome on GitHub at https://github.com/[USERNAME]/missive. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [code of conduct](https://github.com/[USERNAME]/missive/blob/main/CODE_OF_CONDUCT.md).
185
+ # Send the message to the list
186
+ message.send!
187
+ ```
188
+
189
+ ### Sending with Postmark Bulk API
190
+
191
+ Missive leverages the [Bulk Email API](https://postmarkapp.com/developer/api/bulk-email) endpoints.
192
+
193
+ > [!WARNING]
194
+ > This endpoint is available to early access customers only. You need to request access. [Learn more](https://postmarkapp.com/support/article/1311-the-early-access-program-for-the-new-bulk-api)
195
+
196
+ The official [postmark gem](https://github.com/ActiveCampaign/postmark-gem) does not support these endpoints yet, so Missive ships with `Stamp`, a thin layer over Postmark's official library.
197
+
198
+ #### Sending a message in bulk
199
+
200
+ You can pass a Hash that matches the body expected by the API.
201
+
202
+ ```rb
203
+ Missive::Stamp::ApiClient.deliver_in_bulk(
204
+ from: "sender@example.com",
205
+ subject: "Hello {{name}}",
206
+ html_body: "<p>Hello {{name}}</p>",
207
+ text_body: "Hello {{name}}",
208
+ messages: [
209
+ {
210
+ to: "jane.doe@example.com",
211
+ template_model: {name: "Jane"}
212
+ },
213
+ {
214
+ to: "john.doe@example.com",
215
+ template_model: {name: "John"}
216
+ }
217
+ ]
218
+ )
219
+ ```
220
+
221
+ You can also pass a `Mail` instance and an Array of recipients.
222
+
223
+ ```rb
224
+ mail = Mail.new do
225
+ from "sender@example.com"
226
+ subject "Hello {{name}}"
227
+ body: "Hello {{name}}"
228
+ end
229
+
230
+ Missive::Stamp::ApiClient.deliver_message_in_bulk(
231
+ mail,
232
+ [
233
+ {
234
+ to: "jane.doe@example.com",
235
+ template_model: {name: "Jane"}
236
+ },
237
+ {
238
+ to: "john.doe@example.com",
239
+ template_model: {name: "John"}
240
+ }
241
+ ]
242
+ )
243
+ ```
244
+
245
+ #### Getting the status of a bulk API request
246
+
247
+ ```rb
248
+ Missive::Stamp::ApiClient.get_bulk_status("f24af63c-533d-4b7a-ad65-4a7b3202d3a7")
249
+ ```
36
250
 
37
251
  ## License
38
252
 
@@ -40,4 +254,4 @@ The gem is available as open source under the terms of the [MIT License](https:/
40
254
 
41
255
  ## Code of Conduct
42
256
 
43
- Everyone interacting in the Missive project's codebases, issue trackers, chat rooms and mailing lists is expected to follow the [code of conduct](https://github.com/[USERNAME]/missive/blob/main/CODE_OF_CONDUCT.md).
257
+ Everyone interacting in the Missive project's codebases, issue trackers, chat rooms and mailing lists is expected to follow the [code of conduct](https://github.com/Spone/missive/blob/main/CODE_OF_CONDUCT.md).
data/Rakefile CHANGED
@@ -1,10 +1,11 @@
1
- # frozen_string_literal: true
1
+ require "bundler/setup"
2
2
 
3
- require "bundler/gem_tasks"
4
- require "minitest/test_task"
3
+ APP_RAKEFILE = File.expand_path("test/dummy/Rakefile", __dir__)
4
+ load "rails/tasks/engine.rake"
5
5
 
6
- Minitest::TestTask.create
6
+ load "rails/tasks/statistics.rake"
7
7
 
8
8
  require "standard/rake"
9
+ require "bundler/gem_tasks"
9
10
 
10
11
  task default: %i[test standard]
File without changes
@@ -0,0 +1,4 @@
1
+ module Missive
2
+ class ApplicationController < ActionController::Base
3
+ end
4
+ end
@@ -0,0 +1,92 @@
1
+ module Missive
2
+ class Postmark::WebhooksController < ApplicationController
3
+ skip_forgery_protection
4
+
5
+ class RecipientNotMatching < StandardError; end
6
+
7
+ before_action :verify_webhook
8
+ before_action :set_payload, only: :receive
9
+
10
+ rescue_from RecipientNotMatching, with: :handle_bad_request
11
+ rescue_from ActiveRecord::RecordNotFound, with: :handle_not_found
12
+ rescue_from NoMatchingPatternError, with: :handle_no_matching_pattern
13
+
14
+ def receive
15
+ case @payload
16
+ in {RecordType: "Open", ReceivedAt: opened_at}
17
+ set_subscriber! && set_dispatch! && check_dispatch_recipient!
18
+ @dispatch.update!(opened_at:)
19
+ in {RecordType: "Delivery", DeliveredAt: delivered_at}
20
+ set_subscriber! && set_dispatch! && check_dispatch_recipient!
21
+ @dispatch.update!(delivered_at:)
22
+ in {RecordType: "SubscriptionChange", ChangedAt: suppressed_at, SuppressSending: true, SuppressionReason: suppression_reason}
23
+ set_subscriber! && set_dispatch && set_subscription
24
+ reason = suppression_reason.underscore
25
+ @subscriber.suppress!(reason:)
26
+ @dispatch&.suppress!(reason:)
27
+ @subscription&.suppress!(reason:)
28
+ in {RecordType: "SubscriptionChange", SuppressSending: false}
29
+ set_subscriber! && set_dispatch && set_subscription
30
+ @subscriber.unsuppress!
31
+ @dispatch&.unsuppress!
32
+ @subscription&.unsuppress!
33
+ end
34
+
35
+ head :ok
36
+ end
37
+
38
+ private
39
+
40
+ def verify_webhook
41
+ secret_header = request.headers["HTTP_X_POSTMARK_SECRET"]
42
+
43
+ render plain: "Cannot verify webhook", status: :unauthorized if secret_header != webhooks_secret
44
+ end
45
+
46
+ def set_payload
47
+ @payload = JSON.parse(request.body.read).with_indifferent_access
48
+ end
49
+
50
+ def set_dispatch
51
+ @dispatch = Dispatch.includes(:subscriber, :list).find_by(postmark_message_id: @payload["MessageID"])
52
+ end
53
+
54
+ def set_dispatch!
55
+ @dispatch = Dispatch.includes(:subscriber, :list).find_by!(postmark_message_id: @payload["MessageID"])
56
+ end
57
+
58
+ def set_subscriber!
59
+ @subscriber = Subscriber.find_by!(email: @payload["Recipient"])
60
+ end
61
+
62
+ def set_subscription
63
+ return if @dispatch.nil?
64
+
65
+ list = @dispatch.list
66
+ @subscription = Subscription.find_by(list:, subscriber: @subscriber)
67
+ end
68
+
69
+ def check_dispatch_recipient!
70
+ return unless @dispatch.subscriber != @subscriber
71
+
72
+ raise RecipientNotMatching,
73
+ "Dispatch subscriber #{@dispatch.subscriber.email} does not match payload recipient #{@payload["Recipient"]}"
74
+ end
75
+
76
+ def webhooks_secret
77
+ Rails.application.credentials.postmark.webhooks_secret
78
+ end
79
+
80
+ def handle_bad_request(e)
81
+ render plain: e.message, status: :bad_request
82
+ end
83
+
84
+ def handle_not_found(e)
85
+ render plain: "#{e.model} not found", status: :not_found
86
+ end
87
+
88
+ def handle_no_matching_pattern
89
+ render plain: "Webhook payload not supported", status: :unprocessable_entity
90
+ end
91
+ end
92
+ end
@@ -0,0 +1,4 @@
1
+ module Missive
2
+ module ApplicationHelper
3
+ end
4
+ end
@@ -0,0 +1,4 @@
1
+ module Missive
2
+ class ApplicationJob < ActiveJob::Base
3
+ end
4
+ end
@@ -0,0 +1,6 @@
1
+ module Missive
2
+ class ApplicationMailer < ActionMailer::Base
3
+ default from: "from@example.com"
4
+ layout "mailer"
5
+ end
6
+ end
File without changes
@@ -0,0 +1,26 @@
1
+ module Missive
2
+ module Suppressible
3
+ extend ActiveSupport::Concern
4
+
5
+ included do
6
+ time_for_a_boolean :suppressed
7
+
8
+ enum :suppression_reason, %i[hard_bounce spam_complaint manual_suppression]
9
+
10
+ scope :suppressed, -> { where.not(suppressed_at: nil) }
11
+ scope :not_suppressed, -> { where(suppressed_at: nil) }
12
+
13
+ validates :suppression_reason, presence: true, if: :suppressed?
14
+
15
+ def suppress!(reason:)
16
+ self.suppression_reason = reason
17
+ suppressed!
18
+ save!
19
+ end
20
+
21
+ def unsuppress!
22
+ update!(suppressed_at: nil, suppression_reason: nil)
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,10 @@
1
+ module Missive
2
+ module User
3
+ extend ActiveSupport::Concern
4
+
5
+ included do
6
+ include Missive::UserAsSender
7
+ include Missive::UserAsSubscriber
8
+ end
9
+ end
10
+ end
@@ -0,0 +1,19 @@
1
+ module Missive
2
+ module UserAsSender
3
+ extend ActiveSupport::Concern
4
+
5
+ included do
6
+ has_one :sender, class_name: "Missive::Sender", dependent: :nullify
7
+ has_many :sent_dispatches, class_name: "Missive::Dispatch", through: :sender, source: :dispatches
8
+ has_many :sent_lists, class_name: "Missive::List", through: :sender, source: :lists
9
+ has_many :sent_messages, class_name: "Missive::Message", through: :sender, source: :messages
10
+
11
+ def init_sender(attributes = {})
12
+ self.sender = Missive::Sender.find_or_initialize_by(email:)
13
+ sender.assign_attributes(attributes)
14
+ sender.save!
15
+ sender
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,23 @@
1
+ module Missive
2
+ module UserAsSubscriber
3
+ extend ActiveSupport::Concern
4
+
5
+ included do
6
+ has_one :subscriber, class_name: "Missive::Subscriber", dependent: :destroy
7
+ has_many :dispatches, class_name: "Missive::Dispatch", through: :subscriber
8
+ has_many :subscriptions, class_name: "Missive::Subscription", through: :subscriber
9
+ has_many :subscribed_lists, class_name: "Missive::List", through: :subscriber, source: :lists
10
+ has_many :unsubscribed_lists, -> { where.not(missive_subscriptions: {suppressed_at: nil}) },
11
+ class_name: "Missive::List",
12
+ through: :subscriber,
13
+ source: :lists
14
+
15
+ def init_subscriber(attributes = {})
16
+ self.subscriber = Missive::Subscriber.find_or_initialize_by(email:)
17
+ subscriber.assign_attributes(attributes)
18
+ subscriber.save!
19
+ subscriber
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,5 @@
1
+ module Missive
2
+ class ApplicationRecord < ActiveRecord::Base
3
+ self.abstract_class = true
4
+ end
5
+ end
@@ -0,0 +1,19 @@
1
+ module Missive
2
+ class Dispatch < ApplicationRecord
3
+ include Suppressible
4
+
5
+ belongs_to :sender
6
+ belongs_to :subscriber
7
+ belongs_to :message, counter_cache: :dispatches_count
8
+ has_one :list, through: :message
9
+ has_one :subscription, ->(dispatch) { where(list: dispatch.list) }, through: :subscriber, source: :subscriptions
10
+
11
+ time_for_a_boolean :sent
12
+ time_for_a_boolean :delivered
13
+ time_for_a_boolean :opened
14
+ time_for_a_boolean :clicked
15
+
16
+ validates :message, uniqueness: {scope: :subscriber,
17
+ message: "should be dispatched once per subscriber"}
18
+ end
19
+ end
@@ -0,0 +1,10 @@
1
+ module Missive
2
+ class List < ApplicationRecord
3
+ belongs_to :sender
4
+ has_many :messages, dependent: :destroy
5
+ has_many :subscriptions, dependent: :destroy
6
+ has_many :subscribers, through: :subscriptions
7
+
8
+ validates :name, presence: true
9
+ end
10
+ end
@@ -0,0 +1,11 @@
1
+ module Missive
2
+ class Message < ApplicationRecord
3
+ belongs_to :sender
4
+ belongs_to :list, counter_cache: :messages_count
5
+ has_many :dispatches, dependent: :destroy
6
+
7
+ time_for_a_boolean :sent
8
+
9
+ validates :subject, presence: true
10
+ end
11
+ end
@@ -0,0 +1,10 @@
1
+ module Missive
2
+ class Sender < ApplicationRecord
3
+ belongs_to :user, class_name: "::User", optional: true
4
+ has_many :dispatches, dependent: :restrict_with_exception
5
+ has_many :lists, dependent: :restrict_with_exception
6
+ has_many :messages, dependent: :restrict_with_exception
7
+
8
+ validates :email, presence: true
9
+ end
10
+ end
@@ -0,0 +1,12 @@
1
+ module Missive
2
+ class Subscriber < ApplicationRecord
3
+ include Suppressible
4
+
5
+ belongs_to :user, class_name: "::User", optional: true
6
+ has_many :dispatches, dependent: :destroy
7
+ has_many :subscriptions, dependent: :destroy
8
+ has_many :lists, through: :subscriptions
9
+
10
+ validates :email, presence: true
11
+ end
12
+ end
@@ -0,0 +1,11 @@
1
+ module Missive
2
+ class Subscription < ApplicationRecord
3
+ include Suppressible
4
+
5
+ belongs_to :subscriber
6
+ belongs_to :list, counter_cache: :subscriptions_count
7
+
8
+ validates :list, uniqueness: {scope: :subscriber,
9
+ message: "should be subscribed once per subscriber"}
10
+ end
11
+ end
@@ -0,0 +1,17 @@
1
+ <!DOCTYPE html>
2
+ <html>
3
+ <head>
4
+ <title>Missive</title>
5
+ <%= csrf_meta_tags %>
6
+ <%= csp_meta_tag %>
7
+
8
+ <%= yield :head %>
9
+
10
+ <%= stylesheet_link_tag "missive/application", media: "all" %>
11
+ </head>
12
+ <body>
13
+
14
+ <%= yield %>
15
+
16
+ </body>
17
+ </html>
data/config/routes.rb ADDED
@@ -0,0 +1,5 @@
1
+ Missive::Engine.routes.draw do
2
+ namespace :postmark do
3
+ post "webhooks", to: "webhooks#receive"
4
+ end
5
+ end
data/lefthook.yml ADDED
@@ -0,0 +1,13 @@
1
+ # EXAMPLE USAGE:
2
+ #
3
+ # Refer for explanation to following link:
4
+ # https://github.com/evilmartians/lefthook/blob/master/docs/configuration.md
5
+
6
+ pre-commit:
7
+ parallel: true
8
+ commands:
9
+ standard:
10
+ tags: ruby style
11
+ glob: "**/*.rb"
12
+ run: bundle exec rake standard:fix {staged_files}
13
+ stage_fixed: true
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators"
4
+ require "rails/generators/migration"
5
+ require "rails/generators/active_record"
6
+
7
+ module Missive
8
+ module Generators
9
+ class InstallGenerator < Rails::Generators::Base
10
+ include Rails::Generators::Migration
11
+
12
+ source_root File.expand_path("templates", __dir__)
13
+
14
+ def self.next_migration_number(dirname)
15
+ ::ActiveRecord::Generators::Base.next_migration_number(dirname)
16
+ end
17
+
18
+ def copy_missive_migrations
19
+ migration_template "migrations/install_missive.rb.erb", "db/migrate/install_missive.rb"
20
+ end
21
+
22
+ private
23
+
24
+ def migration_class_name
25
+ if Rails::VERSION::MAJOR >= 5
26
+ "ActiveRecord::Migration[#{ActiveRecord::Migration.current_version}]"
27
+ else
28
+ "ActiveRecord::Migration"
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,74 @@
1
+ class InstallMissive < <%= migration_class_name %>
2
+ def change
3
+ create_table :missive_subscribers do |t|
4
+ t.string :email, null: false
5
+ t.timestamp :suppressed_at
6
+ t.integer :suppression_reason
7
+ t.references :user, foreign_key: true
8
+
9
+ t.timestamps
10
+ end
11
+
12
+ create_table :missive_lists do |t|
13
+ t.string :name, null: false
14
+ t.integer :subscriptions_count, default: 0
15
+ t.integer :messages_count, default: 0
16
+ t.timestamp :last_message_sent_at
17
+ t.string :postmark_message_stream_id
18
+
19
+ t.timestamps
20
+ end
21
+
22
+ create_table :missive_messages do |t|
23
+ t.string :subject, null: false
24
+ t.integer :dispatches_count, default: 0
25
+ t.references :list, null: false, foreign_key: {to_table: "missive_lists"}
26
+ t.string :postmark_message_stream_id
27
+ t.timestamp :sent_at
28
+
29
+ t.timestamps
30
+ end
31
+
32
+ create_table :missive_dispatches do |t|
33
+ t.references :subscriber, null: false, foreign_key: {to_table: "missive_subscribers"}
34
+ t.references :message, null: false, foreign_key: {to_table: "missive_messages"}
35
+ t.string :postmark_message_stream_id
36
+ t.string :postmark_message_id
37
+ t.timestamp :sent_at
38
+ t.timestamp :delivered_at
39
+ t.timestamp :opened_at
40
+ t.timestamp :clicked_at
41
+ t.timestamp :suppressed_at
42
+ t.integer :suppression_reason
43
+
44
+ t.index [:subscriber_id, :message_id], unique: true
45
+
46
+ t.timestamps
47
+ end
48
+
49
+ create_table :missive_subscriptions do |t|
50
+ t.references :subscriber, null: false, foreign_key: {to_table: "missive_subscribers"}
51
+ t.references :list, null: false, foreign_key: {to_table: "missive_lists"}
52
+ t.timestamp :suppressed_at
53
+ t.integer :suppression_reason
54
+
55
+ t.index [:subscriber_id, :list_id], unique: true
56
+
57
+ t.timestamps
58
+ end
59
+
60
+ create_table :missive_senders do |t|
61
+ t.string :email, null: false
62
+ t.string :name
63
+ t.string :reply_to_email
64
+ t.integer :postmark_sender_signature_id
65
+ t.references :user, foreign_key: false
66
+
67
+ t.timestamps
68
+ end
69
+
70
+ add_reference :missive_dispatches, :sender, null: false, foreign_key: {to_table: "missive_senders"}
71
+ add_reference :missive_lists, :sender, null: false, foreign_key: {to_table: "missive_senders"}
72
+ add_reference :missive_messages, :sender, null: false, foreign_key: {to_table: "missive_senders"}
73
+ end
74
+ end
@@ -0,0 +1,7 @@
1
+ require "time_for_a_boolean"
2
+
3
+ module Missive
4
+ class Engine < ::Rails::Engine
5
+ isolate_namespace Missive
6
+ end
7
+ end
@@ -0,0 +1,4 @@
1
+ module Missive
2
+ class Railtie < ::Rails::Railtie
3
+ end
4
+ end
@@ -0,0 +1,40 @@
1
+ module Missive
2
+ # Stamp is a thin layer over Postmark's official library,
3
+ # that improves it for Missive's needs.
4
+ #
5
+ # Main features:
6
+ # - implementation of the [Bulk API](https://postmarkapp.com/developer/api/bulk-email)
7
+ module Stamp
8
+ class ApiClient < ::Postmark::ApiClient
9
+ # Send bulk emails, passing a hash
10
+ # https://postmarkapp.com/developer/api/bulk-email#send-bulk-emails
11
+ def deliver_in_bulk(message_hash)
12
+ data = serialize(::Postmark::MessageHelper.to_postmark(message_hash))
13
+
14
+ with_retries do
15
+ format_response http_client.post("email/bulk", data)
16
+ end
17
+ end
18
+
19
+ # Send bulk emails, passing a message and its recipients
20
+ # https://postmarkapp.com/developer/api/bulk-email#send-bulk-emails
21
+ def deliver_message_in_bulk(message, recipients = [])
22
+ data = serialize(message.to_postmark_hash.merge(messages: recipients))
23
+
24
+ with_retries do
25
+ response, error = take_response_of { http_client.post("email/bulk", data) }
26
+ update_message(message, response)
27
+ raise error if error
28
+
29
+ format_response(response, compatible: true)
30
+ end
31
+ end
32
+
33
+ # Get the status/details of a bulk API request
34
+ # https://postmarkapp.com/developer/api/bulk-email#get-a-bulk-send-status
35
+ def get_bulk_status(id)
36
+ format_response http_client.get("email/bulk/#{id}")
37
+ end
38
+ end
39
+ end
40
+ end
@@ -1,5 +1,3 @@
1
- # frozen_string_literal: true
2
-
3
1
  module Missive
4
- VERSION = "0.0.1.pre"
2
+ VERSION = "0.0.2".freeze
5
3
  end
data/lib/missive.rb CHANGED
@@ -1,8 +1,8 @@
1
- # frozen_string_literal: true
2
-
3
- require_relative "missive/version"
1
+ require "missive/version"
2
+ require "missive/engine"
3
+ require "postmark"
4
+ require "missive/stamp/api_client"
4
5
 
5
6
  module Missive
6
- class Error < StandardError; end
7
7
  # Your code goes here...
8
8
  end
@@ -0,0 +1,4 @@
1
+ # desc "Explaining what the task does"
2
+ # task :missive do
3
+ # # Task goes here
4
+ # end
metadata CHANGED
@@ -1,14 +1,70 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: missive
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.0.1.pre
4
+ version: 0.0.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Hans Lemuet
8
8
  bindir: exe
9
9
  cert_chain: []
10
10
  date: 1980-01-02 00:00:00.000000000 Z
11
- dependencies: []
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: rails
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: 8.0.0
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: 8.0.0
26
+ - !ruby/object:Gem::Dependency
27
+ name: postmark-rails
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - ">="
31
+ - !ruby/object:Gem::Version
32
+ version: 0.22.1
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - ">="
38
+ - !ruby/object:Gem::Version
39
+ version: 0.22.1
40
+ - !ruby/object:Gem::Dependency
41
+ name: time_for_a_boolean
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - ">="
45
+ - !ruby/object:Gem::Version
46
+ version: 0.2.1
47
+ type: :runtime
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - ">="
52
+ - !ruby/object:Gem::Version
53
+ version: 0.2.1
54
+ - !ruby/object:Gem::Dependency
55
+ name: rails-pattern_matching
56
+ requirement: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - ">="
59
+ - !ruby/object:Gem::Version
60
+ version: 0.3.0
61
+ type: :runtime
62
+ prerelease: false
63
+ version_requirements: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - ">="
66
+ - !ruby/object:Gem::Version
67
+ version: 0.3.0
12
68
  description: 'Missive provides primitives to build your own custom newsletter: lists,
13
69
  subscribers, content editor, tracking.'
14
70
  email:
@@ -20,11 +76,39 @@ files:
20
76
  - ".standard.yml"
21
77
  - CHANGELOG.md
22
78
  - CODE_OF_CONDUCT.md
79
+ - CONTRIBUTING.md
23
80
  - LICENSE.txt
24
81
  - README.md
25
82
  - Rakefile
83
+ - app/controllers/concerns/.keep
84
+ - app/controllers/missive/application_controller.rb
85
+ - app/controllers/missive/postmark/webhooks_controller.rb
86
+ - app/helpers/missive/application_helper.rb
87
+ - app/jobs/missive/application_job.rb
88
+ - app/mailers/missive/application_mailer.rb
89
+ - app/models/concerns/.keep
90
+ - app/models/concerns/missive/suppressible.rb
91
+ - app/models/concerns/missive/user.rb
92
+ - app/models/concerns/missive/user_as_sender.rb
93
+ - app/models/concerns/missive/user_as_subscriber.rb
94
+ - app/models/missive/application_record.rb
95
+ - app/models/missive/dispatch.rb
96
+ - app/models/missive/list.rb
97
+ - app/models/missive/message.rb
98
+ - app/models/missive/sender.rb
99
+ - app/models/missive/subscriber.rb
100
+ - app/models/missive/subscription.rb
101
+ - app/views/layouts/missive/application.html.erb
102
+ - config/routes.rb
103
+ - lefthook.yml
104
+ - lib/generators/missive/install_generator.rb
105
+ - lib/generators/missive/templates/migrations/install_missive.rb.erb
26
106
  - lib/missive.rb
107
+ - lib/missive/engine.rb
108
+ - lib/missive/railtie.rb
109
+ - lib/missive/stamp/api_client.rb
27
110
  - lib/missive/version.rb
111
+ - lib/tasks/missive_tasks.rake
28
112
  homepage: https://github.com/Spone/missive
29
113
  licenses:
30
114
  - MIT
@@ -46,7 +130,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
46
130
  - !ruby/object:Gem::Version
47
131
  version: '0'
48
132
  requirements: []
49
- rubygems_version: 3.6.7
133
+ rubygems_version: 3.7.2
50
134
  specification_version: 4
51
135
  summary: Toolbox for managing newsletters in Rails, sending them with Postmark.
52
136
  test_files: []