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.
- checksums.yaml +7 -0
- data/.rspec +3 -0
- data/.rubocop.yml +38 -0
- data/CHANGELOG.md +12 -0
- data/README.md +202 -0
- data/Rakefile +12 -0
- data/app/controllers/sincerely/application_controller.rb +81 -0
- data/app/controllers/sincerely/dashboard_controller.rb +112 -0
- data/app/controllers/sincerely/delivery_events_controller.rb +31 -0
- data/app/controllers/sincerely/engagement_events_controller.rb +32 -0
- data/app/controllers/sincerely/notifications_controller.rb +69 -0
- data/app/controllers/sincerely/send_controller.rb +105 -0
- data/app/controllers/sincerely/templates_controller.rb +61 -0
- data/app/helpers/sincerely/application_helper.rb +39 -0
- data/app/views/layouts/sincerely/application.html.erb +593 -0
- data/app/views/sincerely/dashboard/index.html.erb +382 -0
- data/app/views/sincerely/delivery_events/index.html.erb +97 -0
- data/app/views/sincerely/engagement_events/index.html.erb +97 -0
- data/app/views/sincerely/notifications/index.html.erb +91 -0
- data/app/views/sincerely/notifications/show.html.erb +98 -0
- data/app/views/sincerely/send/new.html.erb +592 -0
- data/app/views/sincerely/shared/_pagination.html.erb +19 -0
- data/app/views/sincerely/templates/_form.html.erb +226 -0
- data/app/views/sincerely/templates/edit.html.erb +11 -0
- data/app/views/sincerely/templates/index.html.erb +59 -0
- data/app/views/sincerely/templates/new.html.erb +11 -0
- data/app/views/sincerely/templates/preview.html.erb +48 -0
- data/app/views/sincerely/templates/show.html.erb +69 -0
- data/config/routes.rb +21 -0
- data/lib/config/application_config.rb +18 -0
- data/lib/config/sincerely_config.rb +31 -0
- data/lib/generators/sincerely/aws_ses_webhook_controller_generator.rb +31 -0
- data/lib/generators/sincerely/events_generator.rb +45 -0
- data/lib/generators/sincerely/install_generator.rb +18 -0
- data/lib/generators/sincerely/migration_generator.rb +65 -0
- data/lib/generators/templates/aws_ses_webhook_controller.rb.erb +3 -0
- data/lib/generators/templates/events/delivery_event_model.rb.erb +5 -0
- data/lib/generators/templates/events/delivery_events_create.rb.erb +20 -0
- data/lib/generators/templates/events/engagement_event_model.rb.erb +5 -0
- data/lib/generators/templates/events/engagement_events_create.rb.erb +21 -0
- data/lib/generators/templates/notification_model.rb.erb +3 -0
- data/lib/generators/templates/notifications_create.rb.erb +21 -0
- data/lib/generators/templates/notifications_update.rb.erb +16 -0
- data/lib/generators/templates/sincerely.yml +21 -0
- data/lib/generators/templates/templates_create.rb.erb +15 -0
- data/lib/sincerely/delivery_systems/email_aws_ses.rb +69 -0
- data/lib/sincerely/engine.rb +7 -0
- data/lib/sincerely/mixins/notification_model.rb +94 -0
- data/lib/sincerely/mixins/webhooks/aws_ses_events.rb +74 -0
- data/lib/sincerely/renderers/liquid.rb +14 -0
- data/lib/sincerely/services/events/aws_ses_bounce_event.rb +26 -0
- data/lib/sincerely/services/events/aws_ses_click_event.rb +25 -0
- data/lib/sincerely/services/events/aws_ses_complaint_event.rb +25 -0
- data/lib/sincerely/services/events/aws_ses_delivery_event.rb +17 -0
- data/lib/sincerely/services/events/aws_ses_event.rb +56 -0
- data/lib/sincerely/services/events/aws_ses_open_event.rb +25 -0
- data/lib/sincerely/services/process_delivery_event.rb +72 -0
- data/lib/sincerely/templates/email_liquid_template.rb +13 -0
- data/lib/sincerely/templates/notification_template.rb +22 -0
- data/lib/sincerely/version.rb +5 -0
- data/lib/sincerely.rb +20 -0
- data/sincerely.gemspec +37 -0
- 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
|