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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +7 -0
- data/CONTRIBUTING.md +27 -0
- data/README.md +230 -16
- data/Rakefile +5 -4
- data/app/controllers/concerns/.keep +0 -0
- data/app/controllers/missive/application_controller.rb +4 -0
- data/app/controllers/missive/postmark/webhooks_controller.rb +92 -0
- data/app/helpers/missive/application_helper.rb +4 -0
- data/app/jobs/missive/application_job.rb +4 -0
- data/app/mailers/missive/application_mailer.rb +6 -0
- data/app/models/concerns/.keep +0 -0
- data/app/models/concerns/missive/suppressible.rb +26 -0
- data/app/models/concerns/missive/user.rb +10 -0
- data/app/models/concerns/missive/user_as_sender.rb +19 -0
- data/app/models/concerns/missive/user_as_subscriber.rb +23 -0
- data/app/models/missive/application_record.rb +5 -0
- data/app/models/missive/dispatch.rb +19 -0
- data/app/models/missive/list.rb +10 -0
- data/app/models/missive/message.rb +11 -0
- data/app/models/missive/sender.rb +10 -0
- data/app/models/missive/subscriber.rb +12 -0
- data/app/models/missive/subscription.rb +11 -0
- data/app/views/layouts/missive/application.html.erb +17 -0
- data/config/routes.rb +5 -0
- data/lefthook.yml +13 -0
- data/lib/generators/missive/install_generator.rb +33 -0
- data/lib/generators/missive/templates/migrations/install_missive.rb.erb +74 -0
- data/lib/missive/engine.rb +7 -0
- data/lib/missive/railtie.rb +4 -0
- data/lib/missive/stamp/api_client.rb +40 -0
- data/lib/missive/version.rb +1 -3
- data/lib/missive.rb +4 -4
- data/lib/tasks/missive_tasks.rake +4 -0
- metadata +87 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 63e8195762299f95bdc217efcf1a81dbd8be46ec029ea90b8b4db430af37eabd
|
|
4
|
+
data.tar.gz: 94532fa3c3cd42c9086b35385773c0d2f8483dad96690b0d78523512736aa86e
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: d365c0b22c19489e7d61d46746bec34d58834c167066f02eb71cec287e855a7e820e1eb535e066c2f871cc948338bbbd5151efac7838ca037e2455b36c4dbe07
|
|
7
|
+
data.tar.gz: d7c9d72e65bbac0b683b6f5b69b888547f7ebc5245c35b6b9f582042ad8ef524b775acaed2aa47d17dd4a2be689315c02e41570913edebf7c213b5d0dc728f6f
|
data/CHANGELOG.md
CHANGED
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
|
-
|
|
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
|
-
|
|
5
|
+
## Overview
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
### Features
|
|
8
8
|
|
|
9
|
-
|
|
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
|
-
|
|
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
|
|
55
|
+
bundle add missive
|
|
15
56
|
```
|
|
16
57
|
|
|
17
|
-
|
|
58
|
+
Install the migrations:
|
|
18
59
|
|
|
19
60
|
```bash
|
|
20
|
-
|
|
61
|
+
rails generate missive:install
|
|
21
62
|
```
|
|
22
63
|
|
|
23
|
-
|
|
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
|
-
|
|
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
|
-
|
|
127
|
+
# List the subscriptions
|
|
128
|
+
user.subscriptions # returns a `Missive::Subscription` collection
|
|
28
129
|
|
|
29
|
-
|
|
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
|
-
|
|
134
|
+
# Subscribe to an existing Missive::List
|
|
135
|
+
user.subscriber.subscriptions.create!(list:)
|
|
32
136
|
|
|
33
|
-
|
|
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
|
-
|
|
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/
|
|
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
|
-
|
|
1
|
+
require "bundler/setup"
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
3
|
+
APP_RAKEFILE = File.expand_path("test/dummy/Rakefile", __dir__)
|
|
4
|
+
load "rails/tasks/engine.rake"
|
|
5
5
|
|
|
6
|
-
|
|
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,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
|
|
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,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,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 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
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,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
|
data/lib/missive/version.rb
CHANGED
data/lib/missive.rb
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
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
|
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.
|
|
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.
|
|
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: []
|