missive 0.0.1 → 0.0.3

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: '08e4bc0cf32260c7519cd0ec0ab1b6f378b6e15f658ca559d593f5e2f69a51eb'
4
- data.tar.gz: bb9b7dd6e243f03b4efb6cda902d542b0b7eec9e2e24c1e725f3237a1c39cfdb
3
+ metadata.gz: '06220491a9066084b07838222a15676c94543dcec8d6d7a9e3c84137e8efab14'
4
+ data.tar.gz: cd08afa7a073de913d16f3ffe6251c31c196696420a2f5ccd51d8c1d6bff48f0
5
5
  SHA512:
6
- metadata.gz: 61713bf0d79f835d91a739073efeae708dde7033d2f64aab5f11b696b69483f532c4e795eae4db8d13c256f25b05afdf413d8262fa9a9e2c5062902d41efc82a
7
- data.tar.gz: 9441fbb01803f621f33252a5e1be5fa9ff94fd5c20b5355bfe3a1e0ba44824512404fef1c28378f8ac549ba3f162910a52dbc3e1af478caf9d858b8be3f34257
6
+ metadata.gz: d32d340f9c5c473f320f3590339455b83408fc5854022bb4ad5888f59ee002a08cca4f8ec95bfec9b022d4557af3bad45554f44db137778c5337683c842cfbd2
7
+ data.tar.gz: d15612829c698aa1f4bfa5f2177033da4745a9f4d2b835972d24dea1023a186c7bfe6227b9b2fe61cdecc78cee62b955977089d7f57d775a0ab326063b992072
data/CHANGELOG.md CHANGED
@@ -1,5 +1,14 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [0.0.3] - 2026-02-04
4
+
5
+ - Add associations collision checks and configuration
6
+
7
+ ## [0.0.2] - 2025-12-14
8
+
9
+ - Add install generator
10
+ - Add Postmark Bulk API implementation
11
+
3
12
  ## [0.0.1] - 2025-10-14
4
13
 
5
14
  - First release: base models, database structure
data/README.md CHANGED
@@ -55,6 +55,12 @@ A lightweight Rails toolkit for building newsletter features. Missive provides t
55
55
  bundle add missive
56
56
  ```
57
57
 
58
+ Install the migrations:
59
+
60
+ ```bash
61
+ rails generate missive:install
62
+ ```
63
+
58
64
  ### Configuration
59
65
 
60
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.
@@ -107,6 +113,54 @@ class User < ApplicationRecord
107
113
  end
108
114
  ```
109
115
 
116
+ #### Customizing association names
117
+
118
+ If your User model already has associations named `sender` or `subscriber`, you can customize the association names:
119
+
120
+ ```rb
121
+ class User < ApplicationRecord
122
+ include Missive::User
123
+ configure_missive_sender(
124
+ sender: :missive_sender,
125
+ sent_dispatches: :missive_sent_dispatches,
126
+ sent_lists: :missive_sent_lists,
127
+ sent_messages: :missive_sent_messages
128
+ )
129
+ configure_missive_subscriber(
130
+ subscriber: :missive_subscriber,
131
+ dispatches: :missive_dispatches,
132
+ subscriptions: :missive_subscriptions,
133
+ subscribed_lists: :missive_subscribed_lists,
134
+ unsubscribed_lists: :missive_unsubscribed_lists
135
+ )
136
+ end
137
+ ```
138
+
139
+ Or, if including the concerns separately:
140
+
141
+ ```rb
142
+ class User < ApplicationRecord
143
+ include Missive::UserAsSender
144
+ configure_missive_sender(
145
+ sender: :missive_sender,
146
+ sent_dispatches: :missive_sent_dispatches,
147
+ sent_lists: :missive_sent_lists,
148
+ sent_messages: :missive_sent_messages
149
+ )
150
+
151
+ include Missive::UserAsSubscriber
152
+ configure_missive_subscriber(
153
+ subscriber: :missive_subscriber,
154
+ dispatches: :missive_dispatches,
155
+ subscriptions: :missive_subscriptions,
156
+ subscribed_lists: :missive_subscribed_lists,
157
+ unsubscribed_lists: :missive_unsubscribed_lists
158
+ )
159
+ end
160
+ ```
161
+
162
+ You only need to customize the associations that conflict - any unconfigured associations will use their default names.
163
+
110
164
  #### Manage subscriptions
111
165
 
112
166
  ```rb
@@ -180,6 +234,68 @@ list.create_message!(subject: "Hello world!")
180
234
  message.send!
181
235
  ```
182
236
 
237
+ ### Sending with Postmark Bulk API
238
+
239
+ Missive leverages the [Bulk Email API](https://postmarkapp.com/developer/api/bulk-email) endpoints.
240
+
241
+ > [!WARNING]
242
+ > 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)
243
+
244
+ 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.
245
+
246
+ #### Sending a message in bulk
247
+
248
+ You can pass a Hash that matches the body expected by the API.
249
+
250
+ ```rb
251
+ Missive::Stamp::ApiClient.deliver_in_bulk(
252
+ from: "sender@example.com",
253
+ subject: "Hello {{name}}",
254
+ html_body: "<p>Hello {{name}}</p>",
255
+ text_body: "Hello {{name}}",
256
+ messages: [
257
+ {
258
+ to: "jane.doe@example.com",
259
+ template_model: {name: "Jane"}
260
+ },
261
+ {
262
+ to: "john.doe@example.com",
263
+ template_model: {name: "John"}
264
+ }
265
+ ]
266
+ )
267
+ ```
268
+
269
+ You can also pass a `Mail` instance and an Array of recipients.
270
+
271
+ ```rb
272
+ mail = Mail.new do
273
+ from "sender@example.com"
274
+ subject "Hello {{name}}"
275
+ body: "Hello {{name}}"
276
+ end
277
+
278
+ Missive::Stamp::ApiClient.deliver_message_in_bulk(
279
+ mail,
280
+ [
281
+ {
282
+ to: "jane.doe@example.com",
283
+ template_model: {name: "Jane"}
284
+ },
285
+ {
286
+ to: "john.doe@example.com",
287
+ template_model: {name: "John"}
288
+ }
289
+ ]
290
+ )
291
+ ```
292
+
293
+ #### Getting the status of a bulk API request
294
+
295
+ ```rb
296
+ Missive::Stamp::ApiClient.get_bulk_status("f24af63c-533d-4b7a-ad65-4a7b3202d3a7")
297
+ ```
298
+
183
299
  ## License
184
300
 
185
301
  The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
@@ -2,17 +2,50 @@ module Missive
2
2
  module UserAsSender
3
3
  extend ActiveSupport::Concern
4
4
 
5
+ class AssociationAlreadyDefinedError < StandardError; end
6
+
7
+ ASSOCIATION_NAMES = %i[sender sent_dispatches sent_lists sent_messages].freeze
8
+
9
+ class_methods do
10
+ def missive_sender_config
11
+ @missive_sender_config ||= ASSOCIATION_NAMES.index_with { |name| name }
12
+ end
13
+
14
+ def configure_missive_sender(options = {})
15
+ missive_sender_config.merge!(options)
16
+ _define_missive_sender_associations
17
+ end
18
+
19
+ def _define_missive_sender_associations
20
+ config = missive_sender_config
21
+
22
+ _check_missive_association_collision!(config[:sender])
23
+
24
+ has_one config[:sender], class_name: "Missive::Sender", foreign_key: :user_id, dependent: :nullify
25
+ has_many config[:sent_dispatches], class_name: "Missive::Dispatch", through: config[:sender], source: :dispatches
26
+ has_many config[:sent_lists], class_name: "Missive::List", through: config[:sender], source: :lists
27
+ has_many config[:sent_messages], class_name: "Missive::Message", through: config[:sender], source: :messages
28
+ end
29
+
30
+ def _check_missive_association_collision!(name)
31
+ return unless reflect_on_association(name)
32
+
33
+ raise AssociationAlreadyDefinedError,
34
+ "Association :#{name} is already defined on #{self.name}. " \
35
+ "Use configure_missive_sender to specify a different name. " \
36
+ "Example: configure_missive_sender(sender: :missive_sender)"
37
+ end
38
+ end
39
+
5
40
  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
41
+ _define_missive_sender_associations
10
42
 
11
43
  def init_sender(attributes = {})
12
- self.sender = Missive::Sender.find_or_initialize_by(email:)
13
- sender.assign_attributes(attributes)
14
- sender.save!
15
- sender
44
+ assoc = self.class.missive_sender_config[:sender]
45
+ send("#{assoc}=", Missive::Sender.find_or_initialize_by(email:))
46
+ send(assoc).assign_attributes(attributes)
47
+ send(assoc).save!
48
+ send(assoc)
16
49
  end
17
50
  end
18
51
  end
@@ -2,21 +2,54 @@ module Missive
2
2
  module UserAsSubscriber
3
3
  extend ActiveSupport::Concern
4
4
 
5
+ class AssociationAlreadyDefinedError < StandardError; end
6
+
7
+ ASSOCIATION_NAMES = %i[subscriber dispatches subscriptions subscribed_lists unsubscribed_lists].freeze
8
+
9
+ class_methods do
10
+ def missive_subscriber_config
11
+ @missive_subscriber_config ||= ASSOCIATION_NAMES.index_with { |name| name }
12
+ end
13
+
14
+ def configure_missive_subscriber(options = {})
15
+ missive_subscriber_config.merge!(options)
16
+ _define_missive_subscriber_associations
17
+ end
18
+
19
+ def _define_missive_subscriber_associations
20
+ config = missive_subscriber_config
21
+
22
+ _check_missive_association_collision!(config[:subscriber])
23
+
24
+ has_one config[:subscriber], class_name: "Missive::Subscriber", foreign_key: :user_id, dependent: :destroy
25
+ has_many config[:dispatches], class_name: "Missive::Dispatch", through: config[:subscriber]
26
+ has_many config[:subscriptions], class_name: "Missive::Subscription", through: config[:subscriber]
27
+ has_many config[:subscribed_lists], class_name: "Missive::List", through: config[:subscriber], source: :lists
28
+ has_many config[:unsubscribed_lists], -> { where.not(missive_subscriptions: {suppressed_at: nil}) },
29
+ class_name: "Missive::List",
30
+ through: config[:subscriber],
31
+ source: :lists
32
+ end
33
+
34
+ def _check_missive_association_collision!(name)
35
+ return unless reflect_on_association(name)
36
+
37
+ raise AssociationAlreadyDefinedError,
38
+ "Association :#{name} is already defined on #{self.name}. " \
39
+ "Use configure_missive_subscriber to specify a different name. " \
40
+ "Example: configure_missive_subscriber(subscriber: :missive_subscriber)"
41
+ end
42
+ end
43
+
5
44
  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
45
+ _define_missive_subscriber_associations
14
46
 
15
47
  def init_subscriber(attributes = {})
16
- self.subscriber = Missive::Subscriber.find_or_initialize_by(email:)
17
- subscriber.assign_attributes(attributes)
18
- subscriber.save!
19
- subscriber
48
+ assoc = self.class.missive_subscriber_config[:subscriber]
49
+ send("#{assoc}=", Missive::Subscriber.find_or_initialize_by(email:))
50
+ send(assoc).assign_attributes(attributes)
51
+ send(assoc).save!
52
+ send(assoc)
20
53
  end
21
54
  end
22
55
  end
@@ -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,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,3 +1,3 @@
1
1
  module Missive
2
- VERSION = "0.0.1".freeze
2
+ VERSION = "0.0.3".freeze
3
3
  end
data/lib/missive.rb CHANGED
@@ -1,5 +1,7 @@
1
1
  require "missive/version"
2
2
  require "missive/engine"
3
+ require "postmark"
4
+ require "missive/stamp/api_client"
3
5
 
4
6
  module Missive
5
7
  # Your code goes here...
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: missive
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.0.1
4
+ version: 0.0.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - Hans Lemuet
@@ -100,16 +100,13 @@ files:
100
100
  - app/models/missive/subscription.rb
101
101
  - app/views/layouts/missive/application.html.erb
102
102
  - config/routes.rb
103
- - db/migrate/20251002000005_create_missive_subscribers.rb
104
- - db/migrate/20251004191513_create_missive_lists.rb
105
- - db/migrate/20251004193630_create_missive_messages.rb
106
- - db/migrate/20251004201105_create_missive_dispatches.rb
107
- - db/migrate/20251006214059_create_missive_subscriptions.rb
108
- - db/migrate/20251013205354_create_missive_senders.rb
109
103
  - lefthook.yml
104
+ - lib/generators/missive/install_generator.rb
105
+ - lib/generators/missive/templates/migrations/install_missive.rb.erb
110
106
  - lib/missive.rb
111
107
  - lib/missive/engine.rb
112
108
  - lib/missive/railtie.rb
109
+ - lib/missive/stamp/api_client.rb
113
110
  - lib/missive/version.rb
114
111
  - lib/tasks/missive_tasks.rake
115
112
  homepage: https://github.com/Spone/missive
@@ -133,7 +130,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
133
130
  - !ruby/object:Gem::Version
134
131
  version: '0'
135
132
  requirements: []
136
- rubygems_version: 3.6.7
133
+ rubygems_version: 4.0.4
137
134
  specification_version: 4
138
135
  summary: Toolbox for managing newsletters in Rails, sending them with Postmark.
139
136
  test_files: []
@@ -1,12 +0,0 @@
1
- class CreateMissiveSubscribers < ActiveRecord::Migration[8.0]
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
- end
12
- end
@@ -1,13 +0,0 @@
1
- class CreateMissiveLists < ActiveRecord::Migration[8.0]
2
- def change
3
- create_table :missive_lists do |t|
4
- t.string :name, null: false
5
- t.integer :subscriptions_count, default: 0
6
- t.integer :messages_count, default: 0
7
- t.timestamp :last_message_sent_at
8
- t.string :postmark_message_stream_id
9
-
10
- t.timestamps
11
- end
12
- end
13
- end
@@ -1,13 +0,0 @@
1
- class CreateMissiveMessages < ActiveRecord::Migration[8.0]
2
- def change
3
- create_table :missive_messages do |t|
4
- t.string :subject, null: false
5
- t.integer :dispatches_count, default: 0
6
- t.references :list, null: false, foreign_key: {to_table: "missive_lists"}
7
- t.string :postmark_message_stream_id
8
- t.timestamp :sent_at
9
-
10
- t.timestamps
11
- end
12
- end
13
- end
@@ -1,20 +0,0 @@
1
- class CreateMissiveDispatches < ActiveRecord::Migration[8.0]
2
- def change
3
- create_table :missive_dispatches do |t|
4
- t.references :subscriber, null: false, foreign_key: {to_table: "missive_subscribers"}
5
- t.references :message, null: false, foreign_key: {to_table: "missive_messages"}
6
- t.string :postmark_message_stream_id
7
- t.string :postmark_message_id
8
- t.timestamp :sent_at
9
- t.timestamp :delivered_at
10
- t.timestamp :opened_at
11
- t.timestamp :clicked_at
12
- t.timestamp :suppressed_at
13
- t.integer :suppression_reason
14
-
15
- t.index [:subscriber_id, :message_id], unique: true
16
-
17
- t.timestamps
18
- end
19
- end
20
- end
@@ -1,14 +0,0 @@
1
- class CreateMissiveSubscriptions < ActiveRecord::Migration[8.0]
2
- def change
3
- create_table :missive_subscriptions do |t|
4
- t.references :subscriber, null: false, foreign_key: {to_table: "missive_subscribers"}
5
- t.references :list, null: false, foreign_key: {to_table: "missive_lists"}
6
- t.timestamp :suppressed_at
7
- t.integer :suppression_reason
8
-
9
- t.index [:subscriber_id, :list_id], unique: true
10
-
11
- t.timestamps
12
- end
13
- end
14
- end
@@ -1,17 +0,0 @@
1
- class CreateMissiveSenders < ActiveRecord::Migration[8.0]
2
- def change
3
- create_table :missive_senders do |t|
4
- t.string :email, null: false
5
- t.string :name
6
- t.string :reply_to_email
7
- t.integer :postmark_sender_signature_id
8
- t.references :user, foreign_key: false
9
-
10
- t.timestamps
11
- end
12
-
13
- add_reference :missive_dispatches, :sender, null: false, foreign_key: {to_table: "missive_senders"}
14
- add_reference :missive_lists, :sender, null: false, foreign_key: {to_table: "missive_senders"}
15
- add_reference :missive_messages, :sender, null: false, foreign_key: {to_table: "missive_senders"}
16
- end
17
- end