mailauth 0.3.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/CHANGELOG.md +49 -0
- data/LICENSE +21 -0
- data/README.md +211 -0
- data/lib/mailauth/arc/chain.rb +100 -0
- data/lib/mailauth/arc/message_signature.rb +158 -0
- data/lib/mailauth/arc/seal.rb +97 -0
- data/lib/mailauth/arc/set.rb +74 -0
- data/lib/mailauth/arc.rb +143 -0
- data/lib/mailauth/authentication_results.rb +164 -0
- data/lib/mailauth/dkim/algorithm.rb +28 -0
- data/lib/mailauth/dkim/canonicalization.rb +40 -0
- data/lib/mailauth/dkim/public_key.rb +54 -0
- data/lib/mailauth/dkim/signer.rb +122 -0
- data/lib/mailauth/dkim.rb +270 -0
- data/lib/mailauth/dmarc/alignment.rb +32 -0
- data/lib/mailauth/dmarc/organizational_domain.rb +22 -0
- data/lib/mailauth/dmarc/record.rb +120 -0
- data/lib/mailauth/dmarc.rb +64 -0
- data/lib/mailauth/domain.rb +34 -0
- data/lib/mailauth/message.rb +76 -0
- data/lib/mailauth/resolver.rb +92 -0
- data/lib/mailauth/result.rb +100 -0
- data/lib/mailauth/spf/budget.rb +111 -0
- data/lib/mailauth/spf/macro.rb +121 -0
- data/lib/mailauth/spf/record.rb +188 -0
- data/lib/mailauth/spf.rb +243 -0
- data/lib/mailauth/version.rb +3 -0
- data/lib/mailauth.rb +31 -0
- data/mailauth.gemspec +39 -0
- metadata +116 -0
data/lib/mailauth/spf.rb
ADDED
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
require "ipaddr"
|
|
2
|
+
require "socket"
|
|
3
|
+
|
|
4
|
+
require "mailauth/spf/budget"
|
|
5
|
+
require "mailauth/spf/macro"
|
|
6
|
+
require "mailauth/spf/record"
|
|
7
|
+
|
|
8
|
+
module MailAuth
|
|
9
|
+
# RFC 7208. Was this IP allowed to send for the envelope domain? Note how
|
|
10
|
+
# little a `pass` says: sender and domain are self-declared, and `+all`
|
|
11
|
+
# passes everyone.
|
|
12
|
+
module Spf
|
|
13
|
+
# An `mx` may resolve ten exchanges and a `ptr` ten names — past that a
|
|
14
|
+
# stranger's zone is choosing how much work we do.
|
|
15
|
+
MAX_MX_EXCHANGES = 10
|
|
16
|
+
MAX_PTR_NAMES = 10
|
|
17
|
+
|
|
18
|
+
QUALIFIERS = {
|
|
19
|
+
"+" => Status::PASS, "-" => Status::FAIL, "~" => Status::SOFTFAIL, "?" => Status::NEUTRAL
|
|
20
|
+
}.freeze
|
|
21
|
+
|
|
22
|
+
# Results check_host() reaches without any mechanism matching. Travel as
|
|
23
|
+
# exceptions because they can happen several `include`s deep and end the
|
|
24
|
+
# whole evaluation, not just the record where they were found.
|
|
25
|
+
class Error < StandardError
|
|
26
|
+
def status
|
|
27
|
+
Status::TEMPERROR
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# The DNS didn't answer; the same message may pass later.
|
|
32
|
+
class TempError < Error; end
|
|
33
|
+
|
|
34
|
+
# Broken, or asking more work than it's allowed. Retrying changes nothing.
|
|
35
|
+
class PermError < Error
|
|
36
|
+
def status
|
|
37
|
+
Status::PERMERROR
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Not a failure: the domain publishes no policy at all.
|
|
42
|
+
class NoRecord < Error
|
|
43
|
+
def status
|
|
44
|
+
Status::NONE
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# What a macro can expand to. `domain` is whose record we're reading now,
|
|
49
|
+
# which an `include` or `redirect` moves away from the sender's.
|
|
50
|
+
Context = Data.define(:ip, :sender, :helo, :domain) do
|
|
51
|
+
def local
|
|
52
|
+
sender.split("@", 2).first
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def sender_domain
|
|
56
|
+
sender.split("@", 2).last
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def for_domain(domain)
|
|
60
|
+
with(domain: domain)
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Raises IPAddr::InvalidAddressError on a bad `ip` rather than guessing,
|
|
65
|
+
# which would risk authenticating the wrong host.
|
|
66
|
+
def self.check(ip:, helo: nil, mail_from: nil, resolver: Resolver.new)
|
|
67
|
+
Evaluation.new(ip: ip, helo: helo, mail_from: mail_from, resolver: resolver).run
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# One run of RFC 7208's check_host(), plus every recursion an `include` or
|
|
71
|
+
# `redirect` drags in. Single use: it carries the shared lookup budget.
|
|
72
|
+
class Evaluation
|
|
73
|
+
LABEL = /[a-z0-9](?:[a-z0-9\-]{0,61}[a-z0-9])?/i
|
|
74
|
+
DOMAIN = /\A(?:#{LABEL}\.)+#{Macro::TOPLABEL}\z/o
|
|
75
|
+
|
|
76
|
+
def initialize(ip:, helo:, mail_from:, resolver:)
|
|
77
|
+
@ip = client_address(ip)
|
|
78
|
+
@helo = helo
|
|
79
|
+
@sender = identity(mail_from)
|
|
80
|
+
@domain = @sender.split("@", 2).last.downcase
|
|
81
|
+
@context = Context.new(ip: @ip, sender: @sender, helo: @helo, domain: @domain)
|
|
82
|
+
@budget = Budget.new(resolver, charging: false)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def run
|
|
86
|
+
if checkable?(@domain)
|
|
87
|
+
result check(@domain)
|
|
88
|
+
else
|
|
89
|
+
result Status::NONE
|
|
90
|
+
end
|
|
91
|
+
rescue Error => error
|
|
92
|
+
result error.status, error.message
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
private
|
|
96
|
+
# An IPv4 sender that arrived over IPv6 is still IPv4, and matches
|
|
97
|
+
# only `ip4:` mechanisms.
|
|
98
|
+
def client_address(ip)
|
|
99
|
+
address = IPAddr.new(ip.to_s)
|
|
100
|
+
address.ipv4_mapped? ? address.native : address
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# A bounce has no envelope sender, so there is no domain to check but
|
|
104
|
+
# the one the client announced itself as.
|
|
105
|
+
def identity(mail_from)
|
|
106
|
+
case mail_from.to_s
|
|
107
|
+
when "" then "postmaster@#{@helo}"
|
|
108
|
+
when /\A@/ then "postmaster#{mail_from}"
|
|
109
|
+
when /@/ then mail_from.to_s
|
|
110
|
+
else "postmaster@#{mail_from}"
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# A HELO of "OEMCOMPUTER", a domain literal, anything no record could
|
|
115
|
+
# be published under — answers `none` rather than feigning a check.
|
|
116
|
+
def checkable?(domain)
|
|
117
|
+
domain.length <= Budget::MAX_NAME_LENGTH && domain.match?(DOMAIN)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# check_host() itself. First match decides; `redirect` is consulted only
|
|
121
|
+
# if none matched, which is why any `all` stops a record redirecting.
|
|
122
|
+
def check(domain)
|
|
123
|
+
record = Record.fetch(domain, budget: @budget, context: @context.for_domain(domain))
|
|
124
|
+
|
|
125
|
+
if matched = record.terms.find { |term| matches?(term) }
|
|
126
|
+
QUALIFIERS.fetch(matched.qualifier)
|
|
127
|
+
elsif record.redirect
|
|
128
|
+
redirected(record.redirect)
|
|
129
|
+
else
|
|
130
|
+
Status::NEUTRAL
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def matches?(term)
|
|
135
|
+
case term.mechanism
|
|
136
|
+
when :all then true
|
|
137
|
+
when :ip4, :ip6 then matching_network?(term.network)
|
|
138
|
+
when :a then matching_address?(term.target, term, @budget)
|
|
139
|
+
when :mx then matching_exchange?(term)
|
|
140
|
+
when :exists then exists?(term.target)
|
|
141
|
+
when :include then included?(term.target)
|
|
142
|
+
when :ptr then matching_ptr?(term.target)
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def matching_network?(network)
|
|
147
|
+
network.family == @ip.family && network.include?(@ip)
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def matching_address?(name, term, budget)
|
|
151
|
+
addresses(name, budget).any? { |address| address.mask(prefix_for(term)).include?(@ip) }
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# Only the client's own address family is worth asking about: an IPv6
|
|
155
|
+
# client can never match an A record.
|
|
156
|
+
def addresses(name, budget)
|
|
157
|
+
records = @ip.ipv4? ? budget.a(name) : budget.aaaa(name)
|
|
158
|
+
|
|
159
|
+
records.filter_map { |record| client_family_address(record) }
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def client_family_address(record)
|
|
163
|
+
address = IPAddr.new(record.to_s)
|
|
164
|
+
address if address.family == @ip.family
|
|
165
|
+
rescue IPAddr::Error
|
|
166
|
+
nil # a resolver handing back something that isn't an address tells us nothing
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def prefix_for(term)
|
|
170
|
+
@ip.ipv4? ? term.cidr4 || 32 : term.cidr6 || 128
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def matching_exchange?(term)
|
|
174
|
+
exchanges = @budget.mx(term.target).reject { |exchange| exchange.to_s.chomp(".").empty? }
|
|
175
|
+
|
|
176
|
+
if exchanges.length > MAX_MX_EXCHANGES
|
|
177
|
+
raise PermError, "more than #{MAX_MX_EXCHANGES} MX hosts for #{term.target}"
|
|
178
|
+
else
|
|
179
|
+
addresses = @budget.branch
|
|
180
|
+
exchanges.any? { |exchange| matching_address?(exchange, term, addresses) }
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
# Always an A query, never AAAA, regardless of how the client connected.
|
|
185
|
+
def exists?(name)
|
|
186
|
+
@budget.a(name).any?
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
# Only `pass` in the included record counts — `include` borrows
|
|
190
|
+
# another domain's permissions, not its refusals. Its `none` is our
|
|
191
|
+
# permerror.
|
|
192
|
+
def included?(domain)
|
|
193
|
+
check(domain) == Status::PASS
|
|
194
|
+
rescue NoRecord
|
|
195
|
+
raise PermError, "include target #{domain} publishes no SPF record"
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def redirected(domain)
|
|
199
|
+
check(domain)
|
|
200
|
+
rescue NoRecord
|
|
201
|
+
raise PermError, "redirect target #{domain} publishes no SPF record"
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
# Counts only once the name's own address records point back at the
|
|
205
|
+
# client — the reverse zone belongs to whoever holds the address.
|
|
206
|
+
def matching_ptr?(target)
|
|
207
|
+
names = @budget.ptr(@ip.to_s)
|
|
208
|
+
addresses = @budget.branch
|
|
209
|
+
|
|
210
|
+
names.first(MAX_PTR_NAMES).any? { |name| confirmed?(name, addresses) && under?(name, target) }
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
def confirmed?(name, budget)
|
|
214
|
+
addresses(name, budget).include?(@ip)
|
|
215
|
+
rescue Error
|
|
216
|
+
# A name whose address lookup fails is skipped; the search moves on.
|
|
217
|
+
false
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
def under?(name, target)
|
|
221
|
+
name = name.downcase.chomp(".")
|
|
222
|
+
|
|
223
|
+
name == target || name.end_with?(".#{target}")
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
def result(status, detail = nil)
|
|
227
|
+
SpfResult.new(status: status, domain: @domain, comment: comment(status, detail), lookups: @budget.lookups)
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
def comment(status, detail)
|
|
231
|
+
case status
|
|
232
|
+
when Status::PASS then "domain of #{@sender} designates #{@ip} as permitted sender"
|
|
233
|
+
when Status::FAIL then "domain of #{@sender} does not designate #{@ip} as permitted sender"
|
|
234
|
+
when Status::SOFTFAIL then "domain of transitioning #{@sender} does not designate #{@ip} as permitted sender"
|
|
235
|
+
when Status::NEUTRAL then "#{@ip} is neither permitted nor denied by domain of #{@sender}"
|
|
236
|
+
when Status::NONE then "#{@domain} does not designate permitted sender hosts"
|
|
237
|
+
when Status::PERMERROR then "permanent error in processing #{@domain}: #{detail}"
|
|
238
|
+
when Status::TEMPERROR then "temporary error in processing #{@domain}: #{detail}"
|
|
239
|
+
end
|
|
240
|
+
end
|
|
241
|
+
end
|
|
242
|
+
end
|
|
243
|
+
end
|
data/lib/mailauth.rb
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
require "mailauth/version"
|
|
2
|
+
require "mailauth/result"
|
|
3
|
+
require "mailauth/resolver"
|
|
4
|
+
require "mailauth/message"
|
|
5
|
+
require "mailauth/spf"
|
|
6
|
+
require "mailauth/dkim"
|
|
7
|
+
require "mailauth/dmarc"
|
|
8
|
+
require "mailauth/arc"
|
|
9
|
+
require "mailauth/authentication_results"
|
|
10
|
+
|
|
11
|
+
# Authenticates a received message against SPF, DKIM, DMARC, and ARC, returning
|
|
12
|
+
# the verdicts and the RFC 8601 Authentication-Results header stating them.
|
|
13
|
+
# Says nothing about whether the message is wanted.
|
|
14
|
+
module MailAuth
|
|
15
|
+
def self.authenticate(raw, ip:, helo: nil, mail_from: nil, mta: nil, resolver: Resolver.new, now: Time.now)
|
|
16
|
+
message = Message.new(raw)
|
|
17
|
+
|
|
18
|
+
spf = Spf.check(ip: ip, helo: helo, mail_from: mail_from, resolver: resolver)
|
|
19
|
+
dkim = Dkim.verify(message, resolver: resolver, now: now)
|
|
20
|
+
dmarc = Dmarc.check(header_from: message.header_from_domain, spf: spf, dkim: dkim, resolver: resolver)
|
|
21
|
+
|
|
22
|
+
# DMARC is measured against this hop and ARC reports on earlier ones, so the
|
|
23
|
+
# two are answered apart. Letting a chain speak for DMARC would hand any
|
|
24
|
+
# sender a pass for the price of sealing their own message.
|
|
25
|
+
arc = Arc.verify(message, resolver: resolver, now: now)
|
|
26
|
+
|
|
27
|
+
Result.new(spf:, dkim:, dmarc:, arc:,
|
|
28
|
+
authentication_results: AuthenticationResults.new(spf:, dkim:, dmarc:, arc:, mta:,
|
|
29
|
+
header_from: message.header_from_domain).to_s)
|
|
30
|
+
end
|
|
31
|
+
end
|
data/mailauth.gemspec
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
$LOAD_PATH.push File.expand_path('lib', __dir__)
|
|
2
|
+
require_relative "lib/mailauth/version"
|
|
3
|
+
|
|
4
|
+
Gem::Specification.new do |spec|
|
|
5
|
+
spec.name = "mailauth"
|
|
6
|
+
spec.version = MailAuth::VERSION
|
|
7
|
+
spec.platform = Gem::Platform::RUBY
|
|
8
|
+
spec.required_ruby_version = ">= 3.3.4"
|
|
9
|
+
spec.authors = [ "Simon Lev" ]
|
|
10
|
+
spec.email = [ "support@postrider.dev" ]
|
|
11
|
+
|
|
12
|
+
spec.summary = "SPF, DKIM, DMARC, and ARC verification for received mail, in pure Ruby."
|
|
13
|
+
spec.description = "Answers one question about a received message: is it really from who it says it's from?"
|
|
14
|
+
|
|
15
|
+
spec.homepage = "https://github.com/mailpiece/mailauth"
|
|
16
|
+
spec.license = "MIT"
|
|
17
|
+
|
|
18
|
+
spec.metadata["source_code_uri"] = spec.homepage
|
|
19
|
+
spec.metadata["changelog_uri"] = "#{spec.homepage}/blob/main/CHANGELOG.md"
|
|
20
|
+
spec.metadata["bug_tracker_uri"] = "#{spec.homepage}/issues"
|
|
21
|
+
spec.metadata["rubygems_mfa_required"] = "true"
|
|
22
|
+
|
|
23
|
+
# Listed rather than taken from `git ls-files`, which needs a committed git
|
|
24
|
+
# checkout to be evaluated in — and answers an empty list where there isn't
|
|
25
|
+
# one, building a gem that installs nothing and says so only in a warning.
|
|
26
|
+
spec.files = Dir[
|
|
27
|
+
"lib/**/*.rb",
|
|
28
|
+
"CHANGELOG.md",
|
|
29
|
+
"README.md",
|
|
30
|
+
"LICENSE",
|
|
31
|
+
"mailauth.gemspec"
|
|
32
|
+
]
|
|
33
|
+
spec.require_paths = [ "lib" ]
|
|
34
|
+
|
|
35
|
+
# Resolv collapses SERVFAIL/REFUSED into empty answers; dnsruby does not.
|
|
36
|
+
spec.add_dependency "dnsruby", "~> 1.72"
|
|
37
|
+
spec.add_dependency "public_suffix", ">= 5.0"
|
|
38
|
+
spec.add_dependency "simpleidn", "~> 0.2"
|
|
39
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: mailauth
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.3.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Simon Lev
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: dnsruby
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - "~>"
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '1.72'
|
|
19
|
+
type: :runtime
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - "~>"
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: '1.72'
|
|
26
|
+
- !ruby/object:Gem::Dependency
|
|
27
|
+
name: public_suffix
|
|
28
|
+
requirement: !ruby/object:Gem::Requirement
|
|
29
|
+
requirements:
|
|
30
|
+
- - ">="
|
|
31
|
+
- !ruby/object:Gem::Version
|
|
32
|
+
version: '5.0'
|
|
33
|
+
type: :runtime
|
|
34
|
+
prerelease: false
|
|
35
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
36
|
+
requirements:
|
|
37
|
+
- - ">="
|
|
38
|
+
- !ruby/object:Gem::Version
|
|
39
|
+
version: '5.0'
|
|
40
|
+
- !ruby/object:Gem::Dependency
|
|
41
|
+
name: simpleidn
|
|
42
|
+
requirement: !ruby/object:Gem::Requirement
|
|
43
|
+
requirements:
|
|
44
|
+
- - "~>"
|
|
45
|
+
- !ruby/object:Gem::Version
|
|
46
|
+
version: '0.2'
|
|
47
|
+
type: :runtime
|
|
48
|
+
prerelease: false
|
|
49
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
50
|
+
requirements:
|
|
51
|
+
- - "~>"
|
|
52
|
+
- !ruby/object:Gem::Version
|
|
53
|
+
version: '0.2'
|
|
54
|
+
description: 'Answers one question about a received message: is it really from who
|
|
55
|
+
it says it''s from?'
|
|
56
|
+
email:
|
|
57
|
+
- support@postrider.dev
|
|
58
|
+
executables: []
|
|
59
|
+
extensions: []
|
|
60
|
+
extra_rdoc_files: []
|
|
61
|
+
files:
|
|
62
|
+
- CHANGELOG.md
|
|
63
|
+
- LICENSE
|
|
64
|
+
- README.md
|
|
65
|
+
- lib/mailauth.rb
|
|
66
|
+
- lib/mailauth/arc.rb
|
|
67
|
+
- lib/mailauth/arc/chain.rb
|
|
68
|
+
- lib/mailauth/arc/message_signature.rb
|
|
69
|
+
- lib/mailauth/arc/seal.rb
|
|
70
|
+
- lib/mailauth/arc/set.rb
|
|
71
|
+
- lib/mailauth/authentication_results.rb
|
|
72
|
+
- lib/mailauth/dkim.rb
|
|
73
|
+
- lib/mailauth/dkim/algorithm.rb
|
|
74
|
+
- lib/mailauth/dkim/canonicalization.rb
|
|
75
|
+
- lib/mailauth/dkim/public_key.rb
|
|
76
|
+
- lib/mailauth/dkim/signer.rb
|
|
77
|
+
- lib/mailauth/dmarc.rb
|
|
78
|
+
- lib/mailauth/dmarc/alignment.rb
|
|
79
|
+
- lib/mailauth/dmarc/organizational_domain.rb
|
|
80
|
+
- lib/mailauth/dmarc/record.rb
|
|
81
|
+
- lib/mailauth/domain.rb
|
|
82
|
+
- lib/mailauth/message.rb
|
|
83
|
+
- lib/mailauth/resolver.rb
|
|
84
|
+
- lib/mailauth/result.rb
|
|
85
|
+
- lib/mailauth/spf.rb
|
|
86
|
+
- lib/mailauth/spf/budget.rb
|
|
87
|
+
- lib/mailauth/spf/macro.rb
|
|
88
|
+
- lib/mailauth/spf/record.rb
|
|
89
|
+
- lib/mailauth/version.rb
|
|
90
|
+
- mailauth.gemspec
|
|
91
|
+
homepage: https://github.com/mailpiece/mailauth
|
|
92
|
+
licenses:
|
|
93
|
+
- MIT
|
|
94
|
+
metadata:
|
|
95
|
+
source_code_uri: https://github.com/mailpiece/mailauth
|
|
96
|
+
changelog_uri: https://github.com/mailpiece/mailauth/blob/main/CHANGELOG.md
|
|
97
|
+
bug_tracker_uri: https://github.com/mailpiece/mailauth/issues
|
|
98
|
+
rubygems_mfa_required: 'true'
|
|
99
|
+
rdoc_options: []
|
|
100
|
+
require_paths:
|
|
101
|
+
- lib
|
|
102
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
103
|
+
requirements:
|
|
104
|
+
- - ">="
|
|
105
|
+
- !ruby/object:Gem::Version
|
|
106
|
+
version: 3.3.4
|
|
107
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
108
|
+
requirements:
|
|
109
|
+
- - ">="
|
|
110
|
+
- !ruby/object:Gem::Version
|
|
111
|
+
version: '0'
|
|
112
|
+
requirements: []
|
|
113
|
+
rubygems_version: 4.0.3
|
|
114
|
+
specification_version: 4
|
|
115
|
+
summary: SPF, DKIM, DMARC, and ARC verification for received mail, in pure Ruby.
|
|
116
|
+
test_files: []
|