Skip to content

Commit 3b8efa7

Browse files
committed
Add email attachment support for notification emails (#154)
Add three-level attachment configuration following the same pattern as the existing CC feature (#107): - Global: config.mailer_attachments (Hash, Array, Proc, or nil) - Target: mailer_attachments method on target model - Notifiable: overriding_notification_email_attachments method Attachments support :content (binary data) or :path (local file) with optional :mime_type. Works with both single and batch notification emails.
1 parent 1317b3d commit 3b8efa7

9 files changed

Lines changed: 625 additions & 2 deletions

File tree

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44
Enhancements:
55

66
* Add instance-level subscription support — subscribe to notifications from a specific notifiable instance, not just by notification key - [#202](https://github.com/simukappu/activity_notification/issues/202)
7+
* Add email attachment support for notification emails with three-level configuration (global, target, notifiable) - [#154](https://github.com/simukappu/activity_notification/issues/154)
8+
* Add documentation for `notification_email_allowed?` override - [#206](https://github.com/simukappu/activity_notification/pull/206)
79

810
Breaking Changes:
911

ai-docs/issues/154/design.md

Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
# Design: Email Attachments Support (#154)
2+
3+
## Overview
4+
5+
Follows the same pattern as the CC feature (#107). Three-level configuration, no database changes, integrates into existing mailer helpers.
6+
7+
## Attachment Specification Format
8+
9+
```ruby
10+
{
11+
filename: String, # Required
12+
content: String/Binary, # Either :content or :path required
13+
path: String, # Either :content or :path required
14+
mime_type: String # Optional, inferred from filename if omitted
15+
}
16+
```
17+
18+
## Configuration Levels
19+
20+
### 1. Global (`config.mailer_attachments`)
21+
22+
```ruby
23+
# Single attachment
24+
config.mailer_attachments = { filename: 'terms.pdf', path: Rails.root.join('public', 'terms.pdf') }
25+
26+
# Multiple attachments
27+
config.mailer_attachments = [
28+
{ filename: 'logo.png', path: Rails.root.join('app/assets/images/logo.png') },
29+
{ filename: 'terms.pdf', content: generate_pdf }
30+
]
31+
32+
# Dynamic (Proc receives notification key)
33+
config.mailer_attachments = ->(key) {
34+
key.include?('invoice') ? { filename: 'invoice.pdf', content: generate_invoice } : nil
35+
}
36+
```
37+
38+
### 2. Target (`target.mailer_attachments`)
39+
40+
```ruby
41+
class User < ActiveRecord::Base
42+
acts_as_target email: :email
43+
44+
def mailer_attachments
45+
admin? ? { filename: 'admin_guide.pdf', path: '/path/to/guide.pdf' } : nil
46+
end
47+
end
48+
```
49+
50+
### 3. Notifiable Override (`notifiable.overriding_notification_email_attachments`)
51+
52+
```ruby
53+
class Invoice < ActiveRecord::Base
54+
acts_as_notifiable :users, targets: -> { ... }
55+
56+
def overriding_notification_email_attachments(target, key)
57+
{ filename: "invoice_#{id}.pdf", content: generate_pdf }
58+
end
59+
end
60+
```
61+
62+
## Implementation
63+
64+
### config.rb
65+
66+
Add `mailer_attachments` attribute, initialize to `nil`.
67+
68+
### mailers/helpers.rb
69+
70+
#### New method: `mailer_attachments(target)`
71+
72+
Same pattern as `mailer_cc(target)`:
73+
74+
```ruby
75+
def mailer_attachments(target)
76+
if target.respond_to?(:mailer_attachments)
77+
target.mailer_attachments
78+
elsif ActivityNotification.config.mailer_attachments.present?
79+
if ActivityNotification.config.mailer_attachments.is_a?(Proc)
80+
key = @notification ? @notification.key : nil
81+
ActivityNotification.config.mailer_attachments.call(key)
82+
else
83+
ActivityNotification.config.mailer_attachments
84+
end
85+
else
86+
nil
87+
end
88+
end
89+
```
90+
91+
#### New method: `resolve_attachments(key)`
92+
93+
Resolve with notifiable override priority:
94+
95+
```ruby
96+
def resolve_attachments(key)
97+
if @notification&.notifiable&.respond_to?(:overriding_notification_email_attachments) &&
98+
@notification.notifiable.overriding_notification_email_attachments(@target, key).present?
99+
@notification.notifiable.overriding_notification_email_attachments(@target, key)
100+
else
101+
mailer_attachments(@target)
102+
end
103+
end
104+
```
105+
106+
#### New method: `process_attachments(mail_obj, specs)`
107+
108+
```ruby
109+
def process_attachments(mail_obj, specs)
110+
return if specs.blank?
111+
Array(specs).each do |spec|
112+
next if spec.blank?
113+
validate_attachment_spec!(spec)
114+
content = spec[:content] || File.read(spec[:path])
115+
options = { content: content }
116+
options[:mime_type] = spec[:mime_type] if spec[:mime_type]
117+
mail_obj.attachments[spec[:filename]] = options
118+
end
119+
end
120+
```
121+
122+
#### Modified: `headers_for`
123+
124+
Add attachment resolution, store in headers:
125+
126+
```ruby
127+
attachment_specs = resolve_attachments(key)
128+
headers[:attachment_specs] = attachment_specs if attachment_specs.present?
129+
```
130+
131+
#### Modified: `send_mail`
132+
133+
Extract and process attachments:
134+
135+
```ruby
136+
def send_mail(headers, fallback = nil)
137+
attachment_specs = headers.delete(:attachment_specs)
138+
begin
139+
mail_obj = mail headers
140+
process_attachments(mail_obj, attachment_specs)
141+
mail_obj
142+
rescue ActionView::MissingTemplate => e
143+
if fallback.present?
144+
mail_obj = mail headers.merge(template_name: fallback)
145+
process_attachments(mail_obj, attachment_specs)
146+
mail_obj
147+
else
148+
raise e
149+
end
150+
end
151+
end
152+
```
153+
154+
### Generator Template
155+
156+
Add commented configuration example to `activity_notification.rb` template.

ai-docs/issues/154/requirements.md

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
# Requirements: Email Attachments Support (#154)
2+
3+
## Overview
4+
5+
Add support for email attachments to notification emails. Currently, users must override the mailer to add attachments. This feature provides a clean API following the same pattern as the existing CC feature (#107).
6+
7+
## Requirements
8+
9+
### R1: Global Attachment Configuration
10+
11+
As a developer, I want to configure default attachments for all notification emails at the gem level.
12+
13+
1. `config.mailer_attachments` in the initializer applies attachments to all notification emails
14+
2. Supports Hash (single), Array of Hash (multiple), Proc (dynamic), or nil (none)
15+
3. When Proc, called with notification key as parameter
16+
4. When nil or empty, no attachments added
17+
18+
### R2: Target-Level Attachment Configuration
19+
20+
As a developer, I want to define attachments at the target model level.
21+
22+
1. When target defines `mailer_attachments` method, those attachments are used
23+
2. Returns Array of attachment specs, single Hash, or nil
24+
3. When nil, falls back to global configuration
25+
26+
### R3: Notifiable-Level Attachment Override
27+
28+
As a developer, I want to override attachments per notification type in the notifiable model.
29+
30+
1. When notifiable defines `overriding_notification_email_attachments(target, key)`, used with highest priority
31+
2. Receives target and notification key as parameters
32+
3. When nil, falls back to target-level or global configuration
33+
34+
### R4: Attachment Resolution Priority
35+
36+
1. Priority order: notifiable override > target method > global configuration
37+
2. When higher-priority returns nil, fall back to next level
38+
3. When all return nil, send email without attachments
39+
40+
### R5: Attachment Format
41+
42+
1. Hash with `:filename` (required) and `:content` (binary data)
43+
2. Hash with `:filename` (required) and `:path` (local file path)
44+
3. Optional `:mime_type` key; inferred from filename if not provided
45+
4. Exactly one of `:content` or `:path` must be provided
46+
5. Multiple attachments as Array of Hashes
47+
48+
### R6: Error Handling
49+
50+
1. Missing `:filename` raises ArgumentError
51+
2. Missing both `:content` and `:path` raises ArgumentError
52+
3. Non-existent file path raises ArgumentError
53+
4. Non-Hash spec raises ArgumentError
54+
55+
### R7: Backward Compatibility
56+
57+
1. No attachments when `mailer_attachments` is not configured
58+
2. No database migrations required
59+
3. Existing mailer customizations continue to work
60+
61+
### R8: Batch Notification Attachments
62+
63+
1. Batch notification emails support attachments using the same configuration
64+
2. Same resolution priority applies

ai-docs/issues/154/tasks.md

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
# Tasks: Email Attachments Support (#154)
2+
3+
## Task 1: Configuration
4+
- [ ] Add `mailer_attachments` attr_accessor to Config class
5+
- [ ] Initialize to `nil` in Config#initialize
6+
- [ ] Add YARD documentation
7+
8+
## Task 2: Attachment Validation
9+
- [ ] Add `validate_attachment_spec!(spec)` private method to Mailers::Helpers
10+
- Validate spec is Hash
11+
- Validate `:filename` present
12+
- Validate exactly one of `:content` or `:path` present
13+
- Validate file exists when `:path` provided
14+
- Raise ArgumentError with descriptive messages
15+
16+
## Task 3: Attachment Resolution
17+
- [ ] Add `mailer_attachments(target)` method (same pattern as `mailer_cc`)
18+
- [ ] Add `resolve_attachments(key)` method (notifiable override > target > global)
19+
- [ ] Add `process_attachments(mail_obj, specs)` method
20+
21+
## Task 4: Mailer Integration
22+
- [ ] Modify `headers_for` to call `resolve_attachments` and store in headers
23+
- [ ] Modify `send_mail` to extract and process attachments
24+
25+
## Task 5: Generator Template
26+
- [ ] Add `config.mailer_attachments` example to initializer template
27+
28+
## Task 6: Tests
29+
- [ ] Config attribute tests (nil default, Hash/Array/Proc/nil assignment)
30+
- [ ] `validate_attachment_spec!` tests (valid specs, missing filename, missing content/path, both content and path, non-Hash, non-existent path)
31+
- [ ] `mailer_attachments(target)` resolution tests (target method, global Hash, global Proc, nil fallback)
32+
- [ ] `resolve_attachments` priority tests (notifiable override > target > global, nil fallback at each level)
33+
- [ ] `process_attachments` tests (single Hash, Array, nil, empty, with/without mime_type)
34+
- [ ] Integration: notification email with attachments (single, multiple, no attachments)
35+
- [ ] Integration: batch notification email with attachments
36+
- [ ] Backward compatibility: existing emails without attachments unchanged
37+
- [ ] Verify 100% coverage

docs/Functions.md

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -215,6 +215,76 @@ class Article < ActiveRecord::Base
215215
end
216216
```
217217

218+
#### Email attachments
219+
220+
*activity_notification* supports email attachments at three levels with the same priority order as CC:
221+
222+
1. **Notifiable model override** (highest priority) - using `overriding_notification_email_attachments` method
223+
2. **Target model method** - using `mailer_attachments` method
224+
3. **Global configuration** - using `config.mailer_attachments` setting
225+
226+
Attachments are specified as a Hash (or Array of Hashes) with `:filename` and either `:content` (binary data) or `:path` (local file path). An optional `:mime_type` can be provided; otherwise it is inferred from the filename.
227+
228+
##### Global attachment configuration
229+
230+
Configure default attachments in *activity_notification.rb* initializer:
231+
232+
```ruby
233+
# Single attachment from a local file
234+
config.mailer_attachments = {
235+
filename: 'terms.pdf',
236+
path: Rails.root.join('public', 'terms.pdf')
237+
}
238+
239+
# Multiple attachments
240+
config.mailer_attachments = [
241+
{ filename: 'logo.png', path: Rails.root.join('app/assets/images/logo.png') },
242+
{ filename: 'terms.pdf', path: Rails.root.join('public', 'terms.pdf') }
243+
]
244+
245+
# Dynamic attachments based on notification key
246+
config.mailer_attachments = ->(key) {
247+
if key.include?('invoice')
248+
{ filename: 'invoice.pdf', content: generate_invoice_pdf }
249+
else
250+
nil # No attachments
251+
end
252+
}
253+
```
254+
255+
##### Target-level attachment configuration
256+
257+
Define `mailer_attachments` method in your target model:
258+
259+
```ruby
260+
class User < ActiveRecord::Base
261+
acts_as_target
262+
263+
def mailer_attachments
264+
if admin?
265+
{ filename: 'admin_guide.pdf', path: Rails.root.join('docs', 'admin_guide.pdf') }
266+
else
267+
nil # Falls back to global config
268+
end
269+
end
270+
end
271+
```
272+
273+
##### Notifiable-level attachment override
274+
275+
For per-notification attachments, implement `overriding_notification_email_attachments` in your notifiable model:
276+
277+
```ruby
278+
class Invoice < ActiveRecord::Base
279+
acts_as_notifiable :users,
280+
targets: ->(invoice, key) { [invoice.user] }
281+
282+
def overriding_notification_email_attachments(target, key)
283+
{ filename: "invoice_#{number}.pdf", content: generate_pdf }
284+
end
285+
end
286+
```
287+
218288
#### i18n for email
219289

220290
The subject of notification email can be put in your locale *.yml* files as **mail_subject** field:

lib/activity_notification/config.rb

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,15 @@ class Config
9191
# @return [String, Array<String>, Proc] CC email address(es) for notification email.
9292
attr_accessor :mailer_cc
9393

94+
# @overload mailer_attachments
95+
# Returns attachment specification(s) for notification emails
96+
# @return [Hash, Array<Hash>, Proc, nil] Attachment specification(s) for notification emails.
97+
# @overload mailer_attachments=(value)
98+
# Sets attachment specification(s) for notification emails
99+
# @param [Hash, Array<Hash>, Proc, nil] mailer_attachments The new mailer_attachments
100+
# @return [Hash, Array<Hash>, Proc, nil] Attachment specification(s) for notification emails.
101+
attr_accessor :mailer_attachments
102+
94103
# @overload mailer
95104
# Returns mailer class for email notification
96105
# @return [String] Mailer class for email notification.
@@ -246,6 +255,7 @@ def initialize
246255
@subscribe_to_optional_targets_as_default = nil
247256
@mailer_sender = nil
248257
@mailer_cc = nil
258+
@mailer_attachments = nil
249259
@mailer = 'ActivityNotification::Mailer'
250260
@parent_mailer = 'ActionMailer::Base'
251261
@parent_job = 'ActiveJob::Base'

0 commit comments

Comments
 (0)