al_email_protect 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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 45eeb1de42070ef099e8c97dac39d19b382a24176bb366cae134999a44a8f563
4
+ data.tar.gz: '08d20381439a1b9f8338fa52b62a784fa021b7a6a00823867379d01cec833631'
5
+ SHA512:
6
+ metadata.gz: f035867e44a23b79ea54a50e63fbb6370a284d469fdeb35eabd95daa8b78324c4ce072bd20d2a1f3d1fbd8626f8700636e79256bd3024de7fa1613c9c0d12c9e
7
+ data.tar.gz: cc611264e9d7fb0d5dcb0350b2cd93f50b38ae9bad99510715afb760b917b29bdabb880198e2a9291486d6f47b80353c1a6b4a3e93726f4146944cd0a25c566d
data/CHANGELOG.md ADDED
@@ -0,0 +1,10 @@
1
+ # Changelog
2
+
3
+ ## 1.0.0 — 2026-08-02
4
+
5
+ - Initial release of `al_email_protect`, implementing the email obfuscation proposed in
6
+ [alshedivat/al-folio#3540](https://github.com/alshedivat/al-folio/issues/3540) (source PR
7
+ [#3532](https://github.com/alshedivat/al-folio/pull/3532)).
8
+ - Addresses are split server-side and never appear as `user@host` or `mailto:` in the built HTML.
9
+ - `{% al_email_protect_link %}` tag, `al_email_obfuscate` filter, and `_styles` / `_scripts` asset tags.
10
+ - Vanilla-JS runtime (no jQuery), with a clipboard fallback for non-secure contexts and keyboard activation.
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) al-folio maintainers
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,81 @@
1
+ # al_email_protect
2
+
3
+ Email obfuscation for [al-folio](https://github.com/alshedivat/al-folio) v1.x. Implements the plugin proposed in
4
+ [al-folio#3540](https://github.com/alshedivat/al-folio/issues/3540).
5
+
6
+ ## What it does
7
+
8
+ When `protect_email` is on, addresses are **never emitted as addresses**. The HTML contains no `user@host` string and no
9
+ `mailto:` target; the two halves are carried in separate attributes and rejoined by the browser when a visitor clicks.
10
+
11
+ This is the part that matters. An implementation that ships the plaintext address and rewrites it after
12
+ `DOMContentLoaded` protects nobody — harvesters parse markup, they do not run your JavaScript. The obfuscation here
13
+ happens at build time, in Liquid.
14
+
15
+ Clicking an address copies it to the clipboard and shows a brief toast, rather than opening a mail client.
16
+
17
+ ## Install
18
+
19
+ Add to your `Gemfile`:
20
+
21
+ ```ruby
22
+ gem "al_email_protect", "= 1.0.0"
23
+ ```
24
+
25
+ and to `_config.yml` — **both lists, or the plugin is inert**:
26
+
27
+ ```yaml
28
+ plugins:
29
+ - al_email_protect
30
+
31
+ protect_email: true # off by default
32
+ ```
33
+
34
+ ## Usage
35
+
36
+ The al-folio theme calls the asset tags for you. In your own layouts:
37
+
38
+ ```liquid
39
+ {% al_email_protect_link site.data.socials.email %}
40
+ ```
41
+
42
+ Renders a click-to-copy element when enabled, and a plain `mailto:` link when disabled — so it is safe to call
43
+ unconditionally.
44
+
45
+ For contexts that show an address as text rather than a link (a CV contact block, say):
46
+
47
+ ```liquid
48
+ {{ cv.email | al_email_obfuscate }}
49
+ ```
50
+
51
+ gives `someone [at] example [dot] com` when enabled, and the address unchanged when not.
52
+
53
+ ## Behaviour worth knowing
54
+
55
+ - **Splits on the last `@`.** A quoted local part may legally contain one (RFC 5321).
56
+ - **Falls back rather than mangling.** A value that is not a usable address (`someone@localhost`, no dot in the domain)
57
+ renders as an ordinary `mailto:` link. Publishing a broken contact address is worse than publishing an unprotected one.
58
+ - **Clipboard needs a secure context.** `navigator.clipboard` is undefined over plain HTTP, so the runtime falls back to
59
+ a hidden-textarea copy, and if even that fails it shows the address so the visitor can copy it by hand.
60
+ - **Keyboard accessible.** The element is a focusable `<a>`; Enter and Space both activate it, and the toast is announced
61
+ via `role="status"`.
62
+
63
+ ## Scope
64
+
65
+ This gem owns obfuscation of addresses it is asked to render. It does not scan arbitrary page text for things that look
66
+ like addresses — that is unreliable and would corrupt legitimate content.
67
+
68
+ `al_folio_cv` renders CV contact details; wiring `al_email_obfuscate` into those layouts is a change in that gem, not
69
+ this one. See [`docs/BOUNDARIES.md`](https://github.com/alshedivat/al-folio/blob/main/docs/BOUNDARIES.md).
70
+
71
+ ## Development
72
+
73
+ ```bash
74
+ bundle install
75
+ bundle exec rake test
76
+ npm ci && npm run lint:prettier
77
+ ```
78
+
79
+ ## License
80
+
81
+ MIT
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AlEmailProtect
4
+ VERSION = "1.0.0"
5
+ end
@@ -0,0 +1,163 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "cgi"
4
+ require "jekyll"
5
+ require "liquid"
6
+ require_relative "al_email_protect/version"
7
+
8
+ # Keeps published email addresses away from harvesters.
9
+ #
10
+ # The scraper-facing half is deliberately server-side: when the feature is on,
11
+ # no `mailto:` string and no `user@host` string is present in the HTML at all.
12
+ # Address harvesters read markup, not JavaScript, so an approach that ships the
13
+ # plaintext and merely rewrites it on DOMContentLoaded protects nobody. The
14
+ # address is emitted as two halves and only rejoined in the browser.
15
+ module AlEmailProtect
16
+ PLUGIN_ROOT = File.expand_path("..", __dir__)
17
+ # Jekyll writes a StaticFile to <dest>/<dir>/<name>, where <dir> is relative
18
+ # to the base below. That base must be lib/, not the gem root, or assets land
19
+ # at /lib/assets/... while every tag in this file points at /assets/... .
20
+ LIB_ROOT = __dir__
21
+ ASSETS_ROOT = File.join(LIB_ROOT, "assets")
22
+
23
+ class PluginStaticFile < Jekyll::StaticFile; end
24
+
25
+ module_function
26
+
27
+ def enabled?(site)
28
+ return false unless site
29
+
30
+ site.config["protect_email"] == true
31
+ end
32
+
33
+ # Splits "someone@example.ac.uk" into ["someone", "example.ac.uk"].
34
+ #
35
+ # Splits on the LAST "@" because the local part of an address may legally
36
+ # contain one when quoted (RFC 5321), and getting this backwards would emit a
37
+ # broken address rather than a protected one. Returns nil for anything not
38
+ # usable as an address, so callers can fall back to rendering it verbatim
39
+ # instead of silently publishing a mangled contact.
40
+ def split_address(value)
41
+ address = value.to_s.strip
42
+ return nil if address.empty?
43
+
44
+ local, _, domain = address.rpartition("@")
45
+ return nil if local.empty? || domain.empty?
46
+ return nil unless domain.include?(".")
47
+
48
+ [local, domain]
49
+ end
50
+
51
+ # "someone [at] example [dot] com", for places that show an address as text
52
+ # rather than as a link (CV contact blocks, for instance).
53
+ def obfuscate_text(value)
54
+ parts = split_address(value)
55
+ return value.to_s unless parts
56
+
57
+ local, domain = parts
58
+ "#{local} [at] #{domain.gsub(".", " [dot] ")}"
59
+ end
60
+
61
+ def escape(value)
62
+ CGI.escapeHTML(value.to_s)
63
+ end
64
+
65
+ # Builds "example [dot] com" as alternating styled spans.
66
+ def domain_markup(domain)
67
+ separator = %(<span class="al-email-sep"> [dot] </span>)
68
+ domain.split(".").map { |label| %(<span class="al-email-text">#{escape(label)}</span>) }.join(separator)
69
+ end
70
+
71
+ # [relative_dir, filename] for everything this gem publishes. Exposed so the
72
+ # destination path can be asserted without booting a full Jekyll site.
73
+ def asset_entries
74
+ Dir.glob(File.join(ASSETS_ROOT, "**", "*")).sort.reject { |p| File.directory?(p) }.map do |source_path|
75
+ [File.dirname(source_path).sub("#{LIB_ROOT}/", ""), File.basename(source_path)]
76
+ end
77
+ end
78
+
79
+ # Copies the runtime into the built site. Skipped entirely when the feature is
80
+ # off, so a site that never enables it does not carry the asset.
81
+ class AssetsGenerator < Jekyll::Generator
82
+ safe true
83
+ priority :low
84
+
85
+ def generate(site)
86
+ return unless AlEmailProtect.enabled?(site)
87
+
88
+ Dir.glob(File.join(ASSETS_ROOT, "**", "*")).sort.each do |source_path|
89
+ next if File.directory?(source_path)
90
+
91
+ relative_dir = File.dirname(source_path).sub("#{LIB_ROOT}/", "")
92
+ site.static_files << PluginStaticFile.new(site, LIB_ROOT, relative_dir, File.basename(source_path))
93
+ end
94
+ end
95
+ end
96
+
97
+ class StylesTag < Liquid::Tag
98
+ def render(context)
99
+ site = context.registers[:site]
100
+ return "" unless AlEmailProtect.enabled?(site)
101
+
102
+ baseurl = site.config["baseurl"] || ""
103
+ %(<link rel="stylesheet" href="#{baseurl}/assets/al_email_protect/css/email-protect.css">\n)
104
+ end
105
+ end
106
+
107
+ class ScriptsTag < Liquid::Tag
108
+ def render(context)
109
+ site = context.registers[:site]
110
+ return "" unless AlEmailProtect.enabled?(site)
111
+
112
+ baseurl = site.config["baseurl"] || ""
113
+ %(<script defer src="#{baseurl}/assets/al_email_protect/js/email-protect.js"></script>\n)
114
+ end
115
+ end
116
+
117
+ # {% al_email_protect_link site.data.socials.email %}
118
+ #
119
+ # Emits a click-to-copy element carrying the address in halves. Falls back to
120
+ # a plain mailto: link when the feature is off, so a layout can call this
121
+ # unconditionally and get correct markup either way.
122
+ class LinkTag < Liquid::Tag
123
+ def initialize(tag_name, markup, options)
124
+ super
125
+ @markup = markup.strip
126
+ end
127
+
128
+ def render(context)
129
+ value = context[@markup]
130
+ value = @markup.gsub(/\A["']|["']\z/, "") if value.nil?
131
+ return "" if value.to_s.strip.empty?
132
+
133
+ parts = AlEmailProtect.split_address(value)
134
+ unless AlEmailProtect.enabled?(context.registers[:site]) && parts
135
+ escaped = AlEmailProtect.escape(value)
136
+ return %(<a href="mailto:#{escaped}">#{escaped}</a>)
137
+ end
138
+
139
+ local, domain = parts
140
+ [
141
+ %(<a href="#" class="al-email-protect" data-eu="#{AlEmailProtect.escape(local)}" data-ed="#{AlEmailProtect.escape(domain)}">),
142
+ %(<span class="al-email-text">#{AlEmailProtect.escape(local)}</span>),
143
+ %(<span class="al-email-sep"> [at] </span>),
144
+ AlEmailProtect.domain_markup(domain),
145
+ %(</a>),
146
+ ].join
147
+ end
148
+ end
149
+
150
+ # {{ cv.email | al_email_obfuscate }}
151
+ module Filters
152
+ def al_email_obfuscate(value)
153
+ return value.to_s unless AlEmailProtect.enabled?(@context.registers[:site])
154
+
155
+ AlEmailProtect.obfuscate_text(value)
156
+ end
157
+ end
158
+ end
159
+
160
+ Liquid::Template.register_tag("al_email_protect_styles", AlEmailProtect::StylesTag)
161
+ Liquid::Template.register_tag("al_email_protect_scripts", AlEmailProtect::ScriptsTag)
162
+ Liquid::Template.register_tag("al_email_protect_link", AlEmailProtect::LinkTag)
163
+ Liquid::Template.register_filter(AlEmailProtect::Filters)
@@ -0,0 +1,48 @@
1
+ /* Styling for addresses rendered by al_email_protect.
2
+ Plain CSS with al-folio's CSS custom properties rather than Sass: _sass/ is
3
+ gem-owned by al_folio_core and a plugin must not add to the theme's build. */
4
+
5
+ .al-email-protect {
6
+ cursor: pointer;
7
+ text-decoration: none;
8
+ }
9
+
10
+ .al-email-protect:hover .al-email-text {
11
+ text-decoration: underline;
12
+ }
13
+
14
+ .al-email-text {
15
+ color: var(--global-theme-color, inherit);
16
+ }
17
+
18
+ .al-email-sep {
19
+ color: var(--global-text-color, inherit);
20
+ /* The " [at] " / " [dot] " markers are a rendering of the address, not extra
21
+ words to read out; the surrounding link already carries the full value. */
22
+ user-select: none;
23
+ }
24
+
25
+ #al-email-copied-toast {
26
+ position: fixed;
27
+ bottom: 20px;
28
+ left: 50%;
29
+ transform: translateX(-50%) translateY(80px);
30
+ background: var(--global-theme-color, rgba(0, 0, 0, 0.85));
31
+ color: #fff;
32
+ padding: 8px 20px;
33
+ border-radius: 6px;
34
+ font-size: 0.875rem;
35
+ z-index: 9999;
36
+ transition: transform 0.25s ease;
37
+ pointer-events: none;
38
+ }
39
+
40
+ #al-email-copied-toast.is-visible {
41
+ transform: translateX(-50%) translateY(0);
42
+ }
43
+
44
+ @media (prefers-reduced-motion: reduce) {
45
+ #al-email-copied-toast {
46
+ transition: none;
47
+ }
48
+ }
@@ -0,0 +1,121 @@
1
+ // Rejoins addresses that al_email_protect emitted in halves, and copies them to
2
+ // the clipboard on click. Vanilla by design: al-folio v1 dropped jQuery from the
3
+ // core runtime, and a plugin that reintroduced it would break sites that do not
4
+ // load the bootstrap-compat shim.
5
+ (function () {
6
+ "use strict";
7
+
8
+ var TOAST_ID = "al-email-copied-toast";
9
+ var TOAST_MS = 1800;
10
+ var toastTimer = null;
11
+
12
+ function showToast(message) {
13
+ var toast = document.getElementById(TOAST_ID);
14
+ if (!toast) {
15
+ toast = document.createElement("div");
16
+ toast.id = TOAST_ID;
17
+ // Announce politely so screen readers report the copy without stealing focus.
18
+ toast.setAttribute("role", "status");
19
+ toast.setAttribute("aria-live", "polite");
20
+ document.body.appendChild(toast);
21
+ }
22
+ toast.textContent = message;
23
+ toast.classList.add("is-visible");
24
+
25
+ // Restart the timer rather than stacking them, or a second click hides the
26
+ // toast early while it is still animating in from the first.
27
+ if (toastTimer) {
28
+ clearTimeout(toastTimer);
29
+ }
30
+ toastTimer = setTimeout(function () {
31
+ toast.classList.remove("is-visible");
32
+ toastTimer = null;
33
+ }, TOAST_MS);
34
+ }
35
+
36
+ function legacyCopy(text) {
37
+ var field = document.createElement("textarea");
38
+ field.value = text;
39
+ field.setAttribute("readonly", "");
40
+ field.style.cssText = "position:fixed;top:0;left:0;opacity:0";
41
+ document.body.appendChild(field);
42
+ field.select();
43
+ var copied = false;
44
+ try {
45
+ copied = document.execCommand("copy");
46
+ } catch (err) {
47
+ copied = false;
48
+ }
49
+ document.body.removeChild(field);
50
+ return copied;
51
+ }
52
+
53
+ function addressOf(element) {
54
+ var local = element.getAttribute("data-eu");
55
+ var domain = element.getAttribute("data-ed");
56
+ if (!local || !domain) {
57
+ return null;
58
+ }
59
+ return local + "@" + domain;
60
+ }
61
+
62
+ function copy(element) {
63
+ var address = addressOf(element);
64
+ if (!address) {
65
+ return;
66
+ }
67
+
68
+ // navigator.clipboard is undefined on any page not served over a secure
69
+ // context, which includes plain-HTTP self-hosting and some previews. Fall
70
+ // back rather than throwing, so the click still does something useful.
71
+ if (navigator.clipboard && window.isSecureContext) {
72
+ navigator.clipboard.writeText(address).then(
73
+ function () {
74
+ showToast("Email copied to clipboard");
75
+ },
76
+ function () {
77
+ // Permission can still be refused; surface the address so the visitor
78
+ // can copy it by hand instead of getting silence.
79
+ showToast(address);
80
+ }
81
+ );
82
+ return;
83
+ }
84
+
85
+ showToast(legacyCopy(address) ? "Email copied to clipboard" : address);
86
+ }
87
+
88
+ // Delegated so addresses rendered after load (search palette, lazy includes)
89
+ // are handled without re-binding.
90
+ function onActivate(event) {
91
+ var element = event.target.closest ? event.target.closest(".al-email-protect") : null;
92
+ if (!element) {
93
+ return;
94
+ }
95
+ event.preventDefault();
96
+ copy(element);
97
+ }
98
+
99
+ function ready() {
100
+ document.addEventListener("click", onActivate);
101
+
102
+ // The element is an <a href="#">, so it is already focusable and reachable
103
+ // by keyboard, but Enter fires click while Space does not. Handle Space too.
104
+ document.addEventListener("keydown", function (event) {
105
+ if (event.key !== " " && event.key !== "Spacebar") {
106
+ return;
107
+ }
108
+ var element = event.target.closest ? event.target.closest(".al-email-protect") : null;
109
+ if (element) {
110
+ event.preventDefault();
111
+ copy(element);
112
+ }
113
+ });
114
+ }
115
+
116
+ if (document.readyState === "loading") {
117
+ document.addEventListener("DOMContentLoaded", ready);
118
+ } else {
119
+ ready();
120
+ }
121
+ })();
metadata ADDED
@@ -0,0 +1,142 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: al_email_protect
3
+ version: !ruby/object:Gem::Version
4
+ version: 1.0.0
5
+ platform: ruby
6
+ authors:
7
+ - al-folio maintainers
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2026-08-02 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: jekyll
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: '3.9'
20
+ - - "<"
21
+ - !ruby/object:Gem::Version
22
+ version: '5.0'
23
+ type: :runtime
24
+ prerelease: false
25
+ version_requirements: !ruby/object:Gem::Requirement
26
+ requirements:
27
+ - - ">="
28
+ - !ruby/object:Gem::Version
29
+ version: '3.9'
30
+ - - "<"
31
+ - !ruby/object:Gem::Version
32
+ version: '5.0'
33
+ - !ruby/object:Gem::Dependency
34
+ name: liquid
35
+ requirement: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - ">="
38
+ - !ruby/object:Gem::Version
39
+ version: '4.0'
40
+ - - "<"
41
+ - !ruby/object:Gem::Version
42
+ version: '6.0'
43
+ type: :runtime
44
+ prerelease: false
45
+ version_requirements: !ruby/object:Gem::Requirement
46
+ requirements:
47
+ - - ">="
48
+ - !ruby/object:Gem::Version
49
+ version: '4.0'
50
+ - - "<"
51
+ - !ruby/object:Gem::Version
52
+ version: '6.0'
53
+ - !ruby/object:Gem::Dependency
54
+ name: bundler
55
+ requirement: !ruby/object:Gem::Requirement
56
+ requirements:
57
+ - - ">="
58
+ - !ruby/object:Gem::Version
59
+ version: '2.0'
60
+ - - "<"
61
+ - !ruby/object:Gem::Version
62
+ version: '3.0'
63
+ type: :development
64
+ prerelease: false
65
+ version_requirements: !ruby/object:Gem::Requirement
66
+ requirements:
67
+ - - ">="
68
+ - !ruby/object:Gem::Version
69
+ version: '2.0'
70
+ - - "<"
71
+ - !ruby/object:Gem::Version
72
+ version: '3.0'
73
+ - !ruby/object:Gem::Dependency
74
+ name: rake
75
+ requirement: !ruby/object:Gem::Requirement
76
+ requirements:
77
+ - - "~>"
78
+ - !ruby/object:Gem::Version
79
+ version: '13.0'
80
+ type: :development
81
+ prerelease: false
82
+ version_requirements: !ruby/object:Gem::Requirement
83
+ requirements:
84
+ - - "~>"
85
+ - !ruby/object:Gem::Version
86
+ version: '13.0'
87
+ - !ruby/object:Gem::Dependency
88
+ name: minitest
89
+ requirement: !ruby/object:Gem::Requirement
90
+ requirements:
91
+ - - "~>"
92
+ - !ruby/object:Gem::Version
93
+ version: '5.0'
94
+ type: :development
95
+ prerelease: false
96
+ version_requirements: !ruby/object:Gem::Requirement
97
+ requirements:
98
+ - - "~>"
99
+ - !ruby/object:Gem::Version
100
+ version: '5.0'
101
+ description: 'Hides mailto: addresses from scrapers and copies them to the clipboard
102
+ on click instead.'
103
+ email:
104
+ - maintainers@al-folio.dev
105
+ executables: []
106
+ extensions: []
107
+ extra_rdoc_files: []
108
+ files:
109
+ - CHANGELOG.md
110
+ - LICENSE
111
+ - README.md
112
+ - lib/al_email_protect.rb
113
+ - lib/al_email_protect/version.rb
114
+ - lib/assets/al_email_protect/css/email-protect.css
115
+ - lib/assets/al_email_protect/js/email-protect.js
116
+ homepage: https://github.com/al-org-dev/al-email-protect
117
+ licenses:
118
+ - MIT
119
+ metadata:
120
+ allowed_push_host: https://rubygems.org
121
+ homepage_uri: https://github.com/al-org-dev/al-email-protect
122
+ source_code_uri: https://github.com/al-org-dev/al-email-protect
123
+ post_install_message:
124
+ rdoc_options: []
125
+ require_paths:
126
+ - lib
127
+ required_ruby_version: !ruby/object:Gem::Requirement
128
+ requirements:
129
+ - - ">="
130
+ - !ruby/object:Gem::Version
131
+ version: '2.7'
132
+ required_rubygems_version: !ruby/object:Gem::Requirement
133
+ requirements:
134
+ - - ">="
135
+ - !ruby/object:Gem::Version
136
+ version: '0'
137
+ requirements: []
138
+ rubygems_version: 3.5.22
139
+ signing_key:
140
+ specification_version: 4
141
+ summary: Email obfuscation plugin for al-folio v1.x
142
+ test_files: []