sincerely 1.0.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 (63) hide show
  1. checksums.yaml +7 -0
  2. data/.rspec +3 -0
  3. data/.rubocop.yml +38 -0
  4. data/CHANGELOG.md +12 -0
  5. data/README.md +202 -0
  6. data/Rakefile +12 -0
  7. data/app/controllers/sincerely/application_controller.rb +81 -0
  8. data/app/controllers/sincerely/dashboard_controller.rb +112 -0
  9. data/app/controllers/sincerely/delivery_events_controller.rb +31 -0
  10. data/app/controllers/sincerely/engagement_events_controller.rb +32 -0
  11. data/app/controllers/sincerely/notifications_controller.rb +69 -0
  12. data/app/controllers/sincerely/send_controller.rb +105 -0
  13. data/app/controllers/sincerely/templates_controller.rb +61 -0
  14. data/app/helpers/sincerely/application_helper.rb +39 -0
  15. data/app/views/layouts/sincerely/application.html.erb +593 -0
  16. data/app/views/sincerely/dashboard/index.html.erb +382 -0
  17. data/app/views/sincerely/delivery_events/index.html.erb +97 -0
  18. data/app/views/sincerely/engagement_events/index.html.erb +97 -0
  19. data/app/views/sincerely/notifications/index.html.erb +91 -0
  20. data/app/views/sincerely/notifications/show.html.erb +98 -0
  21. data/app/views/sincerely/send/new.html.erb +592 -0
  22. data/app/views/sincerely/shared/_pagination.html.erb +19 -0
  23. data/app/views/sincerely/templates/_form.html.erb +226 -0
  24. data/app/views/sincerely/templates/edit.html.erb +11 -0
  25. data/app/views/sincerely/templates/index.html.erb +59 -0
  26. data/app/views/sincerely/templates/new.html.erb +11 -0
  27. data/app/views/sincerely/templates/preview.html.erb +48 -0
  28. data/app/views/sincerely/templates/show.html.erb +69 -0
  29. data/config/routes.rb +21 -0
  30. data/lib/config/application_config.rb +18 -0
  31. data/lib/config/sincerely_config.rb +31 -0
  32. data/lib/generators/sincerely/aws_ses_webhook_controller_generator.rb +31 -0
  33. data/lib/generators/sincerely/events_generator.rb +45 -0
  34. data/lib/generators/sincerely/install_generator.rb +18 -0
  35. data/lib/generators/sincerely/migration_generator.rb +65 -0
  36. data/lib/generators/templates/aws_ses_webhook_controller.rb.erb +3 -0
  37. data/lib/generators/templates/events/delivery_event_model.rb.erb +5 -0
  38. data/lib/generators/templates/events/delivery_events_create.rb.erb +20 -0
  39. data/lib/generators/templates/events/engagement_event_model.rb.erb +5 -0
  40. data/lib/generators/templates/events/engagement_events_create.rb.erb +21 -0
  41. data/lib/generators/templates/notification_model.rb.erb +3 -0
  42. data/lib/generators/templates/notifications_create.rb.erb +21 -0
  43. data/lib/generators/templates/notifications_update.rb.erb +16 -0
  44. data/lib/generators/templates/sincerely.yml +21 -0
  45. data/lib/generators/templates/templates_create.rb.erb +15 -0
  46. data/lib/sincerely/delivery_systems/email_aws_ses.rb +69 -0
  47. data/lib/sincerely/engine.rb +7 -0
  48. data/lib/sincerely/mixins/notification_model.rb +94 -0
  49. data/lib/sincerely/mixins/webhooks/aws_ses_events.rb +74 -0
  50. data/lib/sincerely/renderers/liquid.rb +14 -0
  51. data/lib/sincerely/services/events/aws_ses_bounce_event.rb +26 -0
  52. data/lib/sincerely/services/events/aws_ses_click_event.rb +25 -0
  53. data/lib/sincerely/services/events/aws_ses_complaint_event.rb +25 -0
  54. data/lib/sincerely/services/events/aws_ses_delivery_event.rb +17 -0
  55. data/lib/sincerely/services/events/aws_ses_event.rb +56 -0
  56. data/lib/sincerely/services/events/aws_ses_open_event.rb +25 -0
  57. data/lib/sincerely/services/process_delivery_event.rb +72 -0
  58. data/lib/sincerely/templates/email_liquid_template.rb +13 -0
  59. data/lib/sincerely/templates/notification_template.rb +22 -0
  60. data/lib/sincerely/version.rb +5 -0
  61. data/lib/sincerely.rb +20 -0
  62. data/sincerely.gemspec +37 -0
  63. metadata +187 -0
@@ -0,0 +1,226 @@
1
+ <style>
2
+ .preview-tabs {
3
+ display: flex;
4
+ gap: 0;
5
+ margin-bottom: -1px;
6
+ position: relative;
7
+ z-index: 1;
8
+ }
9
+ .preview-tab {
10
+ padding: 0.5rem 1rem;
11
+ background: var(--bg-secondary);
12
+ border: 1px solid var(--border-color);
13
+ border-bottom: none;
14
+ border-radius: 0.375rem 0.375rem 0 0;
15
+ cursor: pointer;
16
+ font-size: 0.8125rem;
17
+ font-weight: 500;
18
+ color: var(--text-secondary);
19
+ transition: all 0.15s;
20
+ display: flex;
21
+ align-items: center;
22
+ gap: 0.375rem;
23
+ }
24
+ .preview-tab:hover { color: var(--text-primary); }
25
+ .preview-tab.active {
26
+ background: var(--bg-primary);
27
+ color: var(--primary);
28
+ border-bottom: 1px solid var(--bg-primary);
29
+ }
30
+ .preview-tab svg { width: 0.875rem; height: 0.875rem; }
31
+ .preview-container { position: relative; }
32
+ .preview-panel { display: none; }
33
+ .preview-panel.active { display: block; }
34
+ .preview-frame {
35
+ width: 100%;
36
+ min-height: 300px;
37
+ border: 1px solid var(--border-color);
38
+ border-radius: 0 0.375rem 0.375rem 0.375rem;
39
+ background: #fff;
40
+ }
41
+ .preview-text {
42
+ width: 100%;
43
+ min-height: 200px;
44
+ padding: 1rem;
45
+ border: 1px solid var(--border-color);
46
+ border-radius: 0 0.375rem 0.375rem 0.375rem;
47
+ background: var(--bg-primary);
48
+ font-family: 'SF Mono', Consolas, monospace;
49
+ font-size: 0.8125rem;
50
+ white-space: pre-wrap;
51
+ word-break: break-word;
52
+ color: var(--text-primary);
53
+ margin: 0;
54
+ overflow: auto;
55
+ }
56
+ </style>
57
+
58
+ <%= form_with model: template, url: form_url, local: true, class: 'template-form' do |f| %>
59
+ <% if template.errors.any? %>
60
+ <div class="alert alert-danger">
61
+ <strong>Please fix the following errors:</strong>
62
+ <ul>
63
+ <% template.errors.full_messages.each do |message| %>
64
+ <li><%= message %></li>
65
+ <% end %>
66
+ </ul>
67
+ </div>
68
+ <% end %>
69
+
70
+ <div class="grid grid-2">
71
+ <div class="card">
72
+ <div class="card-header">
73
+ <h2 class="card-title">Basic Information</h2>
74
+ </div>
75
+ <div class="form-content">
76
+ <div class="form-group">
77
+ <label for="template_name">Name *</label>
78
+ <%= f.text_field :name, class: 'form-control', required: true, placeholder: 'e.g., Welcome Email' %>
79
+ </div>
80
+
81
+ <div class="form-group">
82
+ <label for="template_subject">Subject *</label>
83
+ <%= f.text_field :subject, class: 'form-control', required: true, placeholder: 'e.g., Welcome to {{ app_name }}!' %>
84
+ <small class="form-hint">You can use Liquid variables like {{ name }}, {{ app_name }}, etc.</small>
85
+ </div>
86
+
87
+ <div class="form-group">
88
+ <label for="template_sender">Sender *</label>
89
+ <%= f.text_field :sender, class: 'form-control', required: true, placeholder: 'e.g., noreply@example.com' %>
90
+ </div>
91
+ </div>
92
+ </div>
93
+
94
+ <div class="card">
95
+ <div class="card-header">
96
+ <h2 class="card-title">Tips</h2>
97
+ </div>
98
+ <div class="tips-content">
99
+ <h4>Liquid Variables</h4>
100
+ <p>Use double curly braces to insert variables:</p>
101
+ <code>{{ variable_name }}</code>
102
+
103
+ <h4 style="margin-top: 1rem;">Conditionals</h4>
104
+ <pre>{% if condition %}
105
+ Show this
106
+ {% endif %}</pre>
107
+
108
+ <h4 style="margin-top: 1rem;">Loops</h4>
109
+ <pre>{% for item in items %}
110
+ {{ item.name }}
111
+ {% endfor %}</pre>
112
+ </div>
113
+ </div>
114
+ </div>
115
+
116
+ <div class="card">
117
+ <div class="card-header">
118
+ <h2 class="card-title">HTML Content *</h2>
119
+ </div>
120
+ <div class="form-content">
121
+ <div class="preview-container" data-preview="html">
122
+ <div class="preview-tabs">
123
+ <button type="button" class="preview-tab active" data-target="code">
124
+ <svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4"></path></svg>
125
+ Code
126
+ </button>
127
+ <button type="button" class="preview-tab" data-target="preview">
128
+ <svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"></path></svg>
129
+ Preview
130
+ </button>
131
+ </div>
132
+ <div class="preview-panel active" data-panel="code">
133
+ <%= f.text_area :html_content, class: 'form-control code-editor', rows: 15, required: true, id: 'html_content', placeholder: '<html>
134
+ <body>
135
+ <h1>Hello {{ name }}!</h1>
136
+ <p>Welcome to our platform.</p>
137
+ </body>
138
+ </html>' %>
139
+ </div>
140
+ <div class="preview-panel" data-panel="preview">
141
+ <iframe class="preview-frame" id="html_preview" sandbox="allow-same-origin"></iframe>
142
+ </div>
143
+ </div>
144
+ </div>
145
+ </div>
146
+
147
+ <div class="card">
148
+ <div class="card-header">
149
+ <h2 class="card-title">Text Content *</h2>
150
+ </div>
151
+ <div class="form-content">
152
+ <div class="preview-container" data-preview="text">
153
+ <div class="preview-tabs">
154
+ <button type="button" class="preview-tab active" data-target="code">
155
+ <svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4"></path></svg>
156
+ Code
157
+ </button>
158
+ <button type="button" class="preview-tab" data-target="preview">
159
+ <svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"></path></svg>
160
+ Preview
161
+ </button>
162
+ </div>
163
+ <div class="preview-panel active" data-panel="code">
164
+ <%= f.text_area :text_content, class: 'form-control code-editor', rows: 10, required: true, id: 'text_content', placeholder: 'Hello {{ name }}!
165
+
166
+ Welcome to our platform.' %>
167
+ </div>
168
+ <div class="preview-panel" data-panel="preview">
169
+ <pre class="preview-text" id="text_preview"></pre>
170
+ </div>
171
+ </div>
172
+ </div>
173
+ </div>
174
+
175
+ <div class="form-actions">
176
+ <%= f.submit template.persisted? ? 'Update Template' : 'Create Template', class: 'btn btn-primary' %>
177
+ <a href="<%= template.persisted? ? sincerely.template_path(template) : sincerely.templates_path %>" class="btn btn-secondary">Cancel</a>
178
+ </div>
179
+ <% end %>
180
+
181
+ <script>
182
+ (function() {
183
+ function updateHtmlPreview() {
184
+ const textarea = document.getElementById('html_content');
185
+ const iframe = document.getElementById('html_preview');
186
+ if (!textarea || !iframe) return;
187
+
188
+ iframe.srcdoc = textarea.value || '';
189
+ }
190
+
191
+ function updateTextPreview() {
192
+ const textarea = document.getElementById('text_content');
193
+ const preview = document.getElementById('text_preview');
194
+ if (!textarea || !preview) return;
195
+
196
+ preview.textContent = textarea.value || '';
197
+ }
198
+
199
+ document.querySelectorAll('.preview-container').forEach(container => {
200
+ const tabs = container.querySelectorAll('.preview-tab');
201
+ const panels = container.querySelectorAll('.preview-panel');
202
+ const previewType = container.dataset.preview;
203
+
204
+ tabs.forEach(tab => {
205
+ tab.addEventListener('click', () => {
206
+ const target = tab.dataset.target;
207
+
208
+ tabs.forEach(t => t.classList.remove('active'));
209
+ tab.classList.add('active');
210
+
211
+ panels.forEach(p => {
212
+ p.classList.toggle('active', p.dataset.panel === target);
213
+ });
214
+
215
+ if (target === 'preview') {
216
+ if (previewType === 'html') {
217
+ updateHtmlPreview();
218
+ } else if (previewType === 'text') {
219
+ updateTextPreview();
220
+ }
221
+ }
222
+ });
223
+ });
224
+ });
225
+ })();
226
+ </script>
@@ -0,0 +1,11 @@
1
+ <div class="page-header page-header--visible">
2
+ <div class="page-header-top">
3
+ <button onclick="history.back()" class="btn-back" title="Back">
4
+ <svg width="20" height="20" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"></path></svg>
5
+ </button>
6
+ <h1 class="page-title">Edit Template</h1>
7
+ </div>
8
+ <p class="page-subtitle">Update <%= @template.name %></p>
9
+ </div>
10
+
11
+ <%= render 'form', template: @template, form_url: sincerely.template_path(@template) %>
@@ -0,0 +1,59 @@
1
+ <div class="page-header">
2
+ <h1 class="page-title">Templates</h1>
3
+ <p class="page-subtitle">Manage notification templates</p>
4
+ </div>
5
+
6
+ <div class="card">
7
+ <div class="card-header">
8
+ <h2 class="card-title">All Templates</h2>
9
+ <a href="<%= sincerely.new_template_path %>" class="btn btn-primary">
10
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
11
+ <path d="M12 5v14M5 12h14"></path>
12
+ </svg>
13
+ New Template
14
+ </a>
15
+ </div>
16
+ <div class="table-wrapper">
17
+ <table>
18
+ <thead>
19
+ <tr>
20
+ <th>Name</th>
21
+ <th>Subject</th>
22
+ <th>Sender</th>
23
+ <th>Created</th>
24
+ <th>Actions</th>
25
+ </tr>
26
+ </thead>
27
+ <tbody>
28
+ <% @templates.each do |template| %>
29
+ <tr>
30
+ <td><strong><%= template.name %></strong></td>
31
+ <td><%= truncate(template.subject, length: 50) %></td>
32
+ <td><%= truncate(template.sender, length: 30) %></td>
33
+ <td><%= template.created_at.strftime('%b %d, %Y') %></td>
34
+ <td class="actions-cell">
35
+ <a href="<%= sincerely.template_path(template) %>" class="btn-icon" title="View">
36
+ <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
37
+ <path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"></path>
38
+ <circle cx="12" cy="12" r="3"></circle>
39
+ </svg>
40
+ </a>
41
+ <a href="<%= sincerely.edit_template_path(template) %>" class="btn-icon" title="Edit">
42
+ <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
43
+ <path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"></path>
44
+ <path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"></path>
45
+ </svg>
46
+ </a>
47
+ </td>
48
+ </tr>
49
+ <% end %>
50
+ <% if @templates.empty? %>
51
+ <tr>
52
+ <td colspan="5" class="empty-state">No templates found. <a href="<%= sincerely.new_template_path %>">Create one</a></td>
53
+ </tr>
54
+ <% end %>
55
+ </tbody>
56
+ </table>
57
+ </div>
58
+ <%= render 'sincerely/shared/pagination', pagination: @pagination %>
59
+ </div>
@@ -0,0 +1,11 @@
1
+ <div class="page-header page-header--visible">
2
+ <div class="page-header-top">
3
+ <button onclick="history.back()" class="btn-back" title="Back">
4
+ <svg width="20" height="20" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"></path></svg>
5
+ </button>
6
+ <h1 class="page-title">New Template</h1>
7
+ </div>
8
+ <p class="page-subtitle">Create a new notification template</p>
9
+ </div>
10
+
11
+ <%= render 'form', template: @template, form_url: sincerely.templates_path %>
@@ -0,0 +1,48 @@
1
+ <!DOCTYPE html>
2
+ <html>
3
+ <head>
4
+ <title>Preview: <%= @template.name %></title>
5
+ <style>
6
+ body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; margin: 0; padding: 0; background: #f5f5f5; }
7
+ .preview-header { background: #1f2937; color: white; padding: 1rem 2rem; }
8
+ .preview-header h1 { margin: 0; font-size: 1.25rem; }
9
+ .preview-meta { margin-top: 0.5rem; font-size: 0.875rem; opacity: 0.8; }
10
+ .preview-tabs { display: flex; gap: 0; background: #374151; }
11
+ .preview-tab { padding: 0.75rem 1.5rem; color: #9ca3af; cursor: pointer; border: none; background: none; font-size: 0.875rem; }
12
+ .preview-tab.active { color: white; background: #4f46e5; }
13
+ .preview-content { padding: 2rem; }
14
+ .preview-frame { background: white; border-radius: 8px; box-shadow: 0 1px 3px rgba(0,0,0,0.1); overflow: hidden; }
15
+ .preview-frame-html { padding: 0; }
16
+ .preview-frame-html iframe { width: 100%; height: 600px; border: none; }
17
+ .preview-frame-text { padding: 2rem; white-space: pre-wrap; font-family: monospace; background: #fafafa; }
18
+ </style>
19
+ </head>
20
+ <body>
21
+ <div class="preview-header">
22
+ <h1><%= @template.name %></h1>
23
+ <div class="preview-meta">
24
+ Subject: <%= @rendered_subject %> | From: <%= @template.sender %>
25
+ </div>
26
+ </div>
27
+ <div class="preview-tabs">
28
+ <button class="preview-tab active" onclick="showTab('html')">HTML</button>
29
+ <button class="preview-tab" onclick="showTab('text')">Text</button>
30
+ </div>
31
+ <div class="preview-content">
32
+ <div id="html-preview" class="preview-frame preview-frame-html">
33
+ <iframe srcdoc="<%= ERB::Util.html_escape(@rendered_html) %>"></iframe>
34
+ </div>
35
+ <div id="text-preview" class="preview-frame preview-frame-text" style="display: none;">
36
+ <%= @rendered_text %>
37
+ </div>
38
+ </div>
39
+ <script>
40
+ function showTab(tab) {
41
+ document.querySelectorAll('.preview-tab').forEach(t => t.classList.remove('active'));
42
+ document.querySelector('.preview-tab[onclick="showTab(\'' + tab + '\')"]').classList.add('active');
43
+ document.getElementById('html-preview').style.display = tab === 'html' ? 'block' : 'none';
44
+ document.getElementById('text-preview').style.display = tab === 'text' ? 'block' : 'none';
45
+ }
46
+ </script>
47
+ </body>
48
+ </html>
@@ -0,0 +1,69 @@
1
+ <div class="page-header page-header--visible">
2
+ <div class="page-header-top">
3
+ <button onclick="history.back()" class="btn-back" title="Back">
4
+ <svg width="20" height="20" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"></path></svg>
5
+ </button>
6
+ <h1 class="page-title"><%= @template.name %></h1>
7
+ </div>
8
+ <p class="page-subtitle"><%= @template.sender %></p>
9
+ </div>
10
+
11
+ <div class="grid grid-2">
12
+ <div class="card">
13
+ <div class="card-header">
14
+ <h2 class="card-title">Details</h2>
15
+ <div class="header-actions">
16
+ <a href="<%= sincerely.preview_template_path(@template) %>" class="btn btn-secondary btn-sm" target="_blank">Preview</a>
17
+ <a href="<%= sincerely.edit_template_path(@template) %>" class="btn btn-primary btn-sm">Edit</a>
18
+ </div>
19
+ </div>
20
+ <div class="details-grid">
21
+ <div class="detail-row">
22
+ <span class="detail-label">Subject</span>
23
+ <span class="detail-value"><%= @template.subject %></span>
24
+ </div>
25
+ <div class="detail-row">
26
+ <span class="detail-label">Sender</span>
27
+ <span class="detail-value"><%= @template.sender %></span>
28
+ </div>
29
+ <div class="detail-row">
30
+ <span class="detail-label">Created</span>
31
+ <span class="detail-value"><%= @template.created_at.strftime('%b %d, %Y %H:%M') %></span>
32
+ </div>
33
+ <div class="detail-row">
34
+ <span class="detail-label">Updated</span>
35
+ <span class="detail-value"><%= @template.updated_at.strftime('%b %d, %Y %H:%M') %></span>
36
+ </div>
37
+ </div>
38
+ </div>
39
+
40
+ <div class="card">
41
+ <div class="card-header">
42
+ <h2 class="card-title">Usage Statistics</h2>
43
+ </div>
44
+ <div class="stats-mini">
45
+ <div class="stat-mini">
46
+ <div class="stat-mini-value"><%= @notification_count %></div>
47
+ <div class="stat-mini-label">Notifications Sent</div>
48
+ </div>
49
+ </div>
50
+ </div>
51
+ </div>
52
+
53
+ <div class="card">
54
+ <div class="card-header">
55
+ <h2 class="card-title">HTML Content</h2>
56
+ </div>
57
+ <div class="code-block">
58
+ <pre><%= @template.html_content %></pre>
59
+ </div>
60
+ </div>
61
+
62
+ <div class="card">
63
+ <div class="card-header">
64
+ <h2 class="card-title">Text Content</h2>
65
+ </div>
66
+ <div class="code-block">
67
+ <pre><%= @template.text_content %></pre>
68
+ </div>
69
+ </div>
data/config/routes.rb ADDED
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ Sincerely::Engine.routes.draw do
4
+ root to: 'dashboard#index'
5
+
6
+ resources :notifications, only: %i[index show]
7
+
8
+ resources :templates, except: [:destroy] do
9
+ member do
10
+ get :preview
11
+ end
12
+ end
13
+
14
+ resources :delivery_events, only: [:index]
15
+ resources :engagement_events, only: [:index]
16
+
17
+ # Send emails
18
+ get 'send', to: 'send#new', as: :send_new
19
+ post 'send', to: 'send#create', as: :send_create
20
+ get 'send/template_variables/:id', to: 'send#template_variables', as: :send_template_variables
21
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sincerely
4
+ class << self
5
+ def config
6
+ @config ||= SincerelyConfig.new
7
+ end
8
+
9
+ def notification_model
10
+ config.notification_model_name.constantize
11
+ end
12
+
13
+ # Dashboard authentication hook
14
+ attr_accessor :authenticate_with
15
+ end
16
+
17
+ class Error < StandardError; end
18
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'anyway_config'
4
+
5
+ module Sincerely
6
+ class SincerelyConfig < Anyway::Config
7
+ config_name :sincerely
8
+
9
+ attr_config(notification_model_name: 'Notification')
10
+ attr_config(:delivery_methods)
11
+
12
+ # Return navigation (optional)
13
+ # Set return_url to show a "back to app" link in the footer
14
+ attr_accessor :return_url, :return_label
15
+
16
+ # Logout configuration (optional)
17
+ # Set logout_url to show a logout button in the navigation
18
+ # The button only appears when authentication is configured
19
+ # Set logout_method to :delete for Devise (default is :get)
20
+ attr_accessor :logout_url, :logout_label, :logout_method
21
+
22
+ # Notification filtering (optional)
23
+ # Lambda that returns a hash for where() clause to filter notifications
24
+ # Example: -> { { recipient: session[:user_email] } }
25
+ attr_accessor :filter_notifications_by
26
+
27
+ def delivery_methods
28
+ as_json.dig('values', 'delivery_methods')
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rails/generators'
4
+ require 'rails/generators/active_record'
5
+
6
+ module Sincerely
7
+ module Generators
8
+ class AwsSesWebhookControllerGenerator < ::Rails::Generators::NamedBase
9
+ source_root File.expand_path('../templates', __dir__)
10
+
11
+ desc 'Generates an AWS SES webhook controller and a route file.'
12
+
13
+ def copy_initializer
14
+ template('aws_ses_webhook_controller.rb.erb', "app/controllers/#{file_name}_controller.rb")
15
+ end
16
+
17
+ def routes_config
18
+ destination_path = 'config/routes.rb'
19
+ insert_into_file destination_path, after: 'Rails.application.routes.draw do' do
20
+ routes_content
21
+ end
22
+ end
23
+
24
+ private
25
+
26
+ def routes_content
27
+ "\n resources :#{file_name}, only: :create"
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rails/generators'
4
+ require 'rails/generators/active_record'
5
+
6
+ module Sincerely
7
+ module Generators
8
+ class EventsGenerator < ::Rails::Generators::Base
9
+ include ::Rails::Generators::Migration
10
+
11
+ source_root File.expand_path('../templates', __dir__)
12
+ desc 'Create event models and migrations'
13
+
14
+ def self.next_migration_number(dirname)
15
+ ActiveRecord::Generators::Base.next_migration_number(dirname)
16
+ end
17
+
18
+ def generate_migration_and_models
19
+ generate_migration
20
+
21
+ generate_model
22
+ end
23
+
24
+ private
25
+
26
+ def generate_migration
27
+ migration_template('events/delivery_events_create.rb.erb',
28
+ 'db/migrate/create_sincerely_delivery_events.rb',
29
+ migration_version:)
30
+ migration_template('events/engagement_events_create.rb.erb',
31
+ 'db/migrate/create_sincerely_engagement_events.rb',
32
+ migration_version:)
33
+ end
34
+
35
+ def migration_version
36
+ "[#{ActiveRecord::VERSION::MAJOR}.#{ActiveRecord::VERSION::MINOR}]"
37
+ end
38
+
39
+ def generate_model
40
+ template('events/delivery_event_model.rb.erb', 'app/models/sincerely/delivery_event.rb')
41
+ template('events/engagement_event_model.rb.erb', 'app/models/sincerely/engagement_event.rb')
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rails/generators'
4
+ require 'rails/generators/active_record'
5
+
6
+ module Sincerely
7
+ module Generators
8
+ class InstallGenerator < Rails::Generators::Base
9
+ source_root File.expand_path('../templates', __dir__)
10
+
11
+ desc 'Generates a config file.'
12
+
13
+ def copy_initializer
14
+ template('sincerely.yml', 'config/sincerely.yml')
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rails/generators'
4
+ require 'rails/generators/active_record'
5
+
6
+ module Sincerely
7
+ module Generators
8
+ class MigrationGenerator < ::Rails::Generators::NamedBase
9
+ include ::Rails::Generators::Migration
10
+
11
+ source_root File.expand_path('../templates', __dir__)
12
+ desc 'Installs Sincerely migration and model files.'
13
+
14
+ def install
15
+ if table_exist?
16
+ migration_template('notifications_update.rb.erb', "db/migrate/update_#{plural_file_name}.rb",
17
+ migration_version:)
18
+ add_mixins_to_existing_model
19
+ else
20
+ migration_template('notifications_create.rb.erb', "db/migrate/create_#{plural_file_name}.rb",
21
+ migration_version:)
22
+ generate_notification_model
23
+ end
24
+
25
+ migration_template('templates_create.rb.erb', 'db/migrate/create_notification_templates.rb',
26
+ migration_version:)
27
+ add_model_name_to_config
28
+ end
29
+
30
+ def self.next_migration_number(dirname)
31
+ ActiveRecord::Generators::Base.next_migration_number(dirname)
32
+ end
33
+
34
+ private
35
+
36
+ def migration_version
37
+ "[#{ActiveRecord::VERSION::MAJOR}.#{ActiveRecord::VERSION::MINOR}]"
38
+ end
39
+
40
+ def table_exist?
41
+ ActiveRecord::Base.connection.table_exists?(plural_file_name.to_sym)
42
+ end
43
+
44
+ def add_mixins_to_existing_model
45
+ model_path = "app/models/#{file_name}.rb"
46
+ mixins_code = "\n include Sincerely::Mixins::NotificationModel\n"
47
+
48
+ insert_into_file model_path, after: "class #{class_name} < ApplicationRecord" do
49
+ mixins_code
50
+ end
51
+ end
52
+
53
+ def generate_notification_model
54
+ template('notification_model.rb.erb', "app/models/#{file_name}.rb")
55
+ end
56
+
57
+ def add_model_name_to_config
58
+ model_path = 'config/sincerely.yml'
59
+ insert_into_file model_path, after: 'notification_model_name: ' do
60
+ class_name
61
+ end
62
+ end
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,3 @@
1
+ class <%= class_name %>Controller < ApplicationController
2
+ include Sincerely::Mixins::Webhooks::AwsSesEvents
3
+ end