gitlab-email_handler 0.0.0 → 0.1.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 +4 -4
- data/lib/gitlab/email_handler/custom_email.rb +65 -0
- data/lib/gitlab/email_handler/identification.rb +72 -0
- data/lib/gitlab/email_handler/identifier.rb +53 -0
- data/lib/gitlab/email_handler/mail_key.rb +157 -0
- data/lib/gitlab/email_handler/matchers/base.rb +33 -0
- data/lib/gitlab/email_handler/matchers/create_issue.rb +55 -0
- data/lib/gitlab/email_handler/matchers/create_merge_request.rb +42 -0
- data/lib/gitlab/email_handler/matchers/create_note.rb +30 -0
- data/lib/gitlab/email_handler/matchers/create_note_on_issuable.rb +32 -0
- data/lib/gitlab/email_handler/matchers/service_desk.rb +70 -0
- data/lib/gitlab/email_handler/matchers/unsubscribe.rb +37 -0
- data/lib/gitlab/email_handler/reply_key.rb +29 -0
- data/lib/gitlab/email_handler/target.rb +34 -0
- data/lib/gitlab/email_handler/version.rb +9 -0
- data/lib/gitlab/email_handler.rb +14 -0
- metadata +68 -7
- data/lib/gitlab-email_handler.rb +0 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: a5f1f3376c9afa7036243ffa5c2e9ebe5916b516aa0493126b8105a3d20b3835
|
|
4
|
+
data.tar.gz: 2efd5e7c9c09d88ace45cf77480fdebf8236956d8aaf616ce1413ff644c5757f
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 4c0f7eb203d86315769156dda59a9d20d6fa216a3524d62aacee97bc2408451b7b2d69994c8cbf4849cc9d154eba9a5f128dc8b1dae83a20b03917fcf7502765
|
|
7
|
+
data.tar.gz: 005f7845096ad4b55bbde87fa6bbe3e380bdc43ced7db791a69438c13b9598820ffed3225416c73cf93257adb23a688182efd17f28248d6cd755464b63652b9e
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'reply_key'
|
|
4
|
+
|
|
5
|
+
module Gitlab
|
|
6
|
+
module EmailHandler
|
|
7
|
+
# Helpers for recognising and normalising custom Service Desk email
|
|
8
|
+
# addresses (e.g. support@acme.com). These mirror the parsing in
|
|
9
|
+
# Gitlab::Email::ServiceDesk::CustomEmail but without any database access -
|
|
10
|
+
# whether an address is actually a registered custom email is confirmed by
|
|
11
|
+
# the Topology Service (see CellRouter).
|
|
12
|
+
module CustomEmail
|
|
13
|
+
VERIFICATION_SUBADDRESS = '+verify'
|
|
14
|
+
|
|
15
|
+
# Matches a reply address like "support+<reply_key>@acme.com". The full
|
|
16
|
+
# reply key spans everything between the first "+" and the "@"; we then
|
|
17
|
+
# validate that span against the reply-key format. We capture by position
|
|
18
|
+
# (not a named group) to avoid colliding with the named groups inside
|
|
19
|
+
# FULL_REPLY_KEY_REGEX.
|
|
20
|
+
FULL_REPLY_KEY_ANCHORED = /\A#{ReplyKey::FULL_REPLY_KEY_REGEX}\z/
|
|
21
|
+
REPLY_ADDRESS_REGEX = /\A(?<base>[^+]+)\+(?<full_key>[^@]+)@(?<domain>.+)\z/
|
|
22
|
+
|
|
23
|
+
# Loose email shape check. Custom emails are arbitrary addresses, so this
|
|
24
|
+
# only filters out obvious non-addresses before asking the Topology
|
|
25
|
+
# Service.
|
|
26
|
+
EMAIL_REGEX = /\A[^@\s]+@[^@\s]+\.[^@\s]+\z/
|
|
27
|
+
|
|
28
|
+
module_function
|
|
29
|
+
|
|
30
|
+
# Returns the base custom email address for a candidate recipient, with
|
|
31
|
+
# any "+verify" or "+<reply_key>" sub-addressing removed, or nil if the
|
|
32
|
+
# value doesn't look like an email address.
|
|
33
|
+
#
|
|
34
|
+
# support@acme.com -> support@acme.com
|
|
35
|
+
# support+verify@acme.com -> support@acme.com
|
|
36
|
+
# support+<reply_key>@acme.com -> support@acme.com
|
|
37
|
+
def base_address(address)
|
|
38
|
+
value = address.to_s.strip
|
|
39
|
+
return unless EMAIL_REGEX.match?(value)
|
|
40
|
+
|
|
41
|
+
match = reply_address_match(value)
|
|
42
|
+
return "#{match[:base]}@#{match[:domain]}" if match
|
|
43
|
+
|
|
44
|
+
value.sub(VERIFICATION_SUBADDRESS, '')
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Extracts the reply key from a custom email reply address, or nil.
|
|
48
|
+
# support+<reply_key>@acme.com -> <reply_key>
|
|
49
|
+
def reply_key(address)
|
|
50
|
+
reply_address_match(address)&.[](:full_key)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Matches a reply address and validates the embedded key against the
|
|
54
|
+
# reply-key format, returning the MatchData or nil. Centralising the
|
|
55
|
+
# match avoids running REPLY_ADDRESS_REGEX twice.
|
|
56
|
+
def reply_address_match(address)
|
|
57
|
+
match = REPLY_ADDRESS_REGEX.match(address.to_s)
|
|
58
|
+
return unless match
|
|
59
|
+
return unless FULL_REPLY_KEY_ANCHORED.match?(match[:full_key])
|
|
60
|
+
|
|
61
|
+
match
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'reply_key'
|
|
4
|
+
require_relative 'target'
|
|
5
|
+
|
|
6
|
+
module Gitlab
|
|
7
|
+
module EmailHandler
|
|
8
|
+
# Result of parsing an incoming email key. Carries the handler that matched
|
|
9
|
+
# and the captures relevant to that handler. The captures are handler
|
|
10
|
+
# specific (project_id, project_path, reply_key, namespace_id, etc.).
|
|
11
|
+
#
|
|
12
|
+
# `#target` returns the identified Target (the resource that owns the email),
|
|
13
|
+
# or nil when the email can't be identified (legacy reply keys without an
|
|
14
|
+
# encoded namespace):
|
|
15
|
+
#
|
|
16
|
+
# - Target.project_id - project id encoded directly in the key
|
|
17
|
+
# - Target.namespace_id - decoded offline from a partitioned reply key
|
|
18
|
+
# - Target.route - full project/group path (located by its top-level
|
|
19
|
+
# namespace)
|
|
20
|
+
# - Target.service_desk_project_key_address_slug
|
|
21
|
+
# - opaque service desk project key, matched verbatim
|
|
22
|
+
# against the claimed
|
|
23
|
+
# service_desk_settings.project_key_address_slug
|
|
24
|
+
# - nil - cannot be identified
|
|
25
|
+
Identification = Data.define(:handler, :attributes) do
|
|
26
|
+
def [](key)
|
|
27
|
+
attributes[key]
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def project_id
|
|
31
|
+
attributes[:project_id]
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def project_path
|
|
35
|
+
attributes[:project_path]
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def project_slug
|
|
39
|
+
attributes[:project_slug]
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def incoming_email_token
|
|
43
|
+
attributes[:incoming_email_token]
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Decodes the base36 namespace_id encoded in partitioned reply keys.
|
|
47
|
+
# Returns nil for legacy reply keys and partitioned keys that don't
|
|
48
|
+
# encode a namespace id.
|
|
49
|
+
def decoded_namespace_id
|
|
50
|
+
encoded = attributes[:namespace_id]
|
|
51
|
+
return unless encoded
|
|
52
|
+
|
|
53
|
+
encoded.to_i(ReplyKey::INTEGER_CONVERT_BASE)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# The identified Target for this email, or nil when it can't be identified.
|
|
57
|
+
def target
|
|
58
|
+
namespace_id = decoded_namespace_id
|
|
59
|
+
|
|
60
|
+
if namespace_id
|
|
61
|
+
Target.namespace_id(namespace_id)
|
|
62
|
+
elsif project_id
|
|
63
|
+
Target.project_id(project_id)
|
|
64
|
+
elsif project_path
|
|
65
|
+
Target.route(project_path)
|
|
66
|
+
elsif attributes[:project_key_address_slug]
|
|
67
|
+
Target.service_desk_project_key_address_slug(attributes[:project_key_address_slug])
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'matchers/create_note'
|
|
4
|
+
require_relative 'matchers/create_issue'
|
|
5
|
+
require_relative 'matchers/create_note_on_issuable'
|
|
6
|
+
require_relative 'matchers/unsubscribe'
|
|
7
|
+
require_relative 'matchers/create_merge_request'
|
|
8
|
+
require_relative 'matchers/service_desk'
|
|
9
|
+
|
|
10
|
+
module Gitlab
|
|
11
|
+
module EmailHandler
|
|
12
|
+
# Identifies which handler an incoming email key belongs to by trying each
|
|
13
|
+
# matcher in the same precedence order as
|
|
14
|
+
# Gitlab::Email::Handler.load_handlers.
|
|
15
|
+
module Identifier
|
|
16
|
+
# Order mirrors Gitlab::Email::Handler.load_handlers.
|
|
17
|
+
MATCHERS = [
|
|
18
|
+
Matchers::CreateNote,
|
|
19
|
+
Matchers::CreateIssue,
|
|
20
|
+
Matchers::CreateNoteOnIssuable,
|
|
21
|
+
Matchers::Unsubscribe,
|
|
22
|
+
Matchers::CreateMergeRequest,
|
|
23
|
+
Matchers::ServiceDesk
|
|
24
|
+
].freeze
|
|
25
|
+
|
|
26
|
+
class << self
|
|
27
|
+
def matchers
|
|
28
|
+
@matchers ||= MATCHERS.map(&:new)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# @param mail_key [String]
|
|
32
|
+
# @return [Gitlab::EmailHandler::Identification, nil]
|
|
33
|
+
def call(mail_key)
|
|
34
|
+
return if mail_key.nil?
|
|
35
|
+
|
|
36
|
+
matchers.each do |matcher|
|
|
37
|
+
result = matcher.match(mail_key)
|
|
38
|
+
return result if result
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
nil
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Convenience: returns an Identification for a specific handler only.
|
|
45
|
+
# Used by the GitLab handlers which already know their own type.
|
|
46
|
+
def for_handler(handler_name, mail_key)
|
|
47
|
+
matcher = matchers.find { |m| m.handler_name == handler_name }
|
|
48
|
+
matcher&.match(mail_key)
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Gitlab
|
|
4
|
+
module EmailHandler
|
|
5
|
+
# Extracts the mail key from an incoming email's headers.
|
|
6
|
+
#
|
|
7
|
+
# This is the single source of truth for *how* an incoming email is scanned
|
|
8
|
+
# for a mail key: the order in which recipient headers (and the references
|
|
9
|
+
# fallback) are tried, how a wildcard address is parsed into a key, and how a
|
|
10
|
+
# fallback message id is parsed. Both the GitLab application
|
|
11
|
+
# (Gitlab::Email::Receiver) and the standalone mail_room service consume this
|
|
12
|
+
# so they can never disagree on which key an email resolves to.
|
|
13
|
+
#
|
|
14
|
+
# The module is pure and config-free: callers pass the wildcard address and
|
|
15
|
+
# the GitLab host as arguments rather than reading them from any global
|
|
16
|
+
# configuration.
|
|
17
|
+
#
|
|
18
|
+
# Callers own *what* to do with each candidate (the application layers in
|
|
19
|
+
# database-backed Service Desk custom email lookups; the mail_room service
|
|
20
|
+
# maps each candidate to a routing Target), but never the ordering. They pass
|
|
21
|
+
# a block to `each_candidate`; the first non-nil value the block returns wins
|
|
22
|
+
# and iteration stops, mirroring the original short-circuiting behavior.
|
|
23
|
+
module MailKey
|
|
24
|
+
# Recipient sources, in the precedence the GitLab application has always
|
|
25
|
+
# used. `:references` is the message-id fallback and is tried after the
|
|
26
|
+
# primary `:to` header but before the remaining headers. `:received`
|
|
27
|
+
# extracts recipients from the Received headers.
|
|
28
|
+
RECIPIENT_HEADERS = %i[
|
|
29
|
+
delivered_to x_delivered_to envelope_to x_envelope_to received x_original_to x_forwarded_to cc
|
|
30
|
+
].freeze
|
|
31
|
+
|
|
32
|
+
# Extracts the recipient address out of a `Received` header, e.g.
|
|
33
|
+
# Received: from x by y for <incoming+key@host>; <date>
|
|
34
|
+
RECEIVED_HEADER_REGEX = /for\s+<([^<]+)>/
|
|
35
|
+
|
|
36
|
+
WILDCARD_PLACEHOLDER = '%{key}'
|
|
37
|
+
|
|
38
|
+
# A single mail key candidate.
|
|
39
|
+
#
|
|
40
|
+
# - source: the header the candidate came from (e.g. :to, :references)
|
|
41
|
+
# - value: the raw recipient address, or message id for :references
|
|
42
|
+
# - key: the offline-parsed mail key for this candidate, or nil
|
|
43
|
+
Candidate = Data.define(:source, :value, :key)
|
|
44
|
+
|
|
45
|
+
module_function
|
|
46
|
+
|
|
47
|
+
# Yields each mail key candidate, in precedence order, to the given block.
|
|
48
|
+
# The first non-nil block result is returned and iteration stops. Returns
|
|
49
|
+
# nil when no candidate is found or the block matches nothing.
|
|
50
|
+
#
|
|
51
|
+
# @param mail [Mail::Message]
|
|
52
|
+
# @param wildcard_address [String, nil] the configured incoming/service
|
|
53
|
+
# desk address containing the `%{key}` placeholder
|
|
54
|
+
# @param gitlab_host [String, nil] the GitLab host, used to parse the
|
|
55
|
+
# references message-id fallback
|
|
56
|
+
# @yieldparam candidate [Candidate]
|
|
57
|
+
# @return the first non-nil block result, or nil
|
|
58
|
+
def each_candidate(mail, wildcard_address:, gitlab_host:)
|
|
59
|
+
return unless block_given?
|
|
60
|
+
|
|
61
|
+
# 1. The primary To header.
|
|
62
|
+
addresses(mail, :to).each do |address|
|
|
63
|
+
result = yield Candidate.new(source: :to, value: address, key: key_from_address(address, wildcard_address))
|
|
64
|
+
return result if result
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# 2. The references message-id fallback.
|
|
68
|
+
reference_message_ids(mail).each do |mail_id|
|
|
69
|
+
result = yield Candidate.new(
|
|
70
|
+
source: :references,
|
|
71
|
+
value: mail_id,
|
|
72
|
+
key: key_from_fallback_message_id(mail_id, gitlab_host)
|
|
73
|
+
)
|
|
74
|
+
return result if result
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# 3. The remaining recipient headers.
|
|
78
|
+
RECIPIENT_HEADERS.each do |source|
|
|
79
|
+
addresses(mail, source).each do |address|
|
|
80
|
+
result = yield Candidate.new(source: source, value: address,
|
|
81
|
+
key: key_from_address(address, wildcard_address))
|
|
82
|
+
return result if result
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
nil
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Parses a mail key out of a recipient address using the wildcard address.
|
|
90
|
+
#
|
|
91
|
+
# wildcard "incoming+%{key}@host", address "incoming+abc@host" => "abc"
|
|
92
|
+
#
|
|
93
|
+
# @return [String, nil]
|
|
94
|
+
def key_from_address(address, wildcard_address)
|
|
95
|
+
regex = address_regex(wildcard_address)
|
|
96
|
+
return unless regex
|
|
97
|
+
|
|
98
|
+
address.to_s.match(regex)&.[](1)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Parses a mail key out of a fallback message id of the form
|
|
102
|
+
# `reply-<key>@<gitlab_host>`.
|
|
103
|
+
#
|
|
104
|
+
# @return [String, nil]
|
|
105
|
+
def key_from_fallback_message_id(mail_id, gitlab_host)
|
|
106
|
+
return unless gitlab_host
|
|
107
|
+
|
|
108
|
+
mail_id.to_s[/\Areply-(.+)@#{Regexp.escape(gitlab_host)}\z/, 1]
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Splits a raw References header value into individual message ids. Handles
|
|
112
|
+
# clients that join references with commas (e.g. Microsoft Exchange, iOS).
|
|
113
|
+
#
|
|
114
|
+
# @return [Array<String>]
|
|
115
|
+
def scan_fallback_references(references)
|
|
116
|
+
# Looks for each <...>
|
|
117
|
+
references.to_s.scan(/(?!<)[^<>]+(?=>)/)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# The addresses for a recipient source, normalized to strings.
|
|
121
|
+
#
|
|
122
|
+
# `:to` and `:cc` use the Mail::Message readers, which return arrays of
|
|
123
|
+
# individual address strings. Every other header is read as a raw field via
|
|
124
|
+
# `mail[header]`, matching how Gitlab::Email::Receiver has always read them.
|
|
125
|
+
def addresses(mail, source)
|
|
126
|
+
case source
|
|
127
|
+
when :to then Array(mail.to)
|
|
128
|
+
when :cc then Array(mail.cc)
|
|
129
|
+
when :received then received_recipients(mail)
|
|
130
|
+
else
|
|
131
|
+
Array(mail[source]).map { |item| item.is_a?(Mail::Field) ? item.value : item.to_s }
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def received_recipients(mail)
|
|
136
|
+
Array(mail[:received]).filter_map { |header| header.value[RECEIVED_HEADER_REGEX, 1] }
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def reference_message_ids(mail)
|
|
140
|
+
references = mail.references
|
|
141
|
+
|
|
142
|
+
case references
|
|
143
|
+
when Array then references
|
|
144
|
+
when String then scan_fallback_references(references)
|
|
145
|
+
else []
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def address_regex(wildcard_address)
|
|
150
|
+
return unless wildcard_address
|
|
151
|
+
|
|
152
|
+
escaped = Regexp.escape(wildcard_address).sub(Regexp.escape(WILDCARD_PLACEHOLDER), '(.+)')
|
|
153
|
+
/\A<?#{escaped}>?\z/
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative '../reply_key'
|
|
4
|
+
require_relative '../identification'
|
|
5
|
+
|
|
6
|
+
module Gitlab
|
|
7
|
+
module EmailHandler
|
|
8
|
+
module Matchers
|
|
9
|
+
# Base class for handler matchers. Each matcher mirrors exactly one email
|
|
10
|
+
# handler's mail key parsing and the database-free portion of its
|
|
11
|
+
# `can_handle?` logic. Matchers never touch the database or the network.
|
|
12
|
+
class Base
|
|
13
|
+
# @param mail_key [String]
|
|
14
|
+
# @return [Gitlab::EmailHandler::Identification, nil]
|
|
15
|
+
def match(mail_key)
|
|
16
|
+
raise NotImplementedError
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# The handler name this matcher corresponds to (e.g. :create_issue).
|
|
20
|
+
# @return [Symbol]
|
|
21
|
+
def handler_name
|
|
22
|
+
raise NotImplementedError
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
private
|
|
26
|
+
|
|
27
|
+
def identification(**attributes)
|
|
28
|
+
Identification.new(handler: handler_name, attributes: attributes)
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'base'
|
|
4
|
+
|
|
5
|
+
module Gitlab
|
|
6
|
+
module EmailHandler
|
|
7
|
+
module Matchers
|
|
8
|
+
# Mirrors Gitlab::Email::Handler::CreateIssueHandler.
|
|
9
|
+
# incoming+gitlab-org-gitlab-ce-20-Author_Token-issue@incoming.gitlab.com
|
|
10
|
+
# incoming+gitlab-org/gitlab-ce+Author_Token@incoming.gitlab.com (legacy)
|
|
11
|
+
class CreateIssue < Base
|
|
12
|
+
HANDLER_REGEX = /\A#{ReplyKey::HANDLER_ACTION_BASE_REGEX}-(?<incoming_email_token>.+)-issue\z/
|
|
13
|
+
HANDLER_REGEX_LEGACY = /\A(?<project_path>[^+]*)\+(?<incoming_email_token>.*)\z/
|
|
14
|
+
|
|
15
|
+
def match(mail_key)
|
|
16
|
+
key = mail_key.to_s
|
|
17
|
+
|
|
18
|
+
matched = HANDLER_REGEX.match(key) unless mail_key&.include?('/')
|
|
19
|
+
if matched
|
|
20
|
+
return identification(
|
|
21
|
+
project_slug: matched[:project_slug],
|
|
22
|
+
project_id: matched[:project_id]&.to_i,
|
|
23
|
+
incoming_email_token: matched[:incoming_email_token]
|
|
24
|
+
)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
matched = HANDLER_REGEX_LEGACY.match(key)
|
|
28
|
+
return unless matched
|
|
29
|
+
|
|
30
|
+
project_path = matched[:project_path]
|
|
31
|
+
incoming_email_token = matched[:incoming_email_token]
|
|
32
|
+
return unless can_handle_legacy_format?(project_path, incoming_email_token, key)
|
|
33
|
+
|
|
34
|
+
identification(
|
|
35
|
+
project_path: project_path,
|
|
36
|
+
incoming_email_token: incoming_email_token
|
|
37
|
+
)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def handler_name
|
|
41
|
+
:create_issue
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
def can_handle_legacy_format?(project_path, incoming_email_token, mail_key)
|
|
47
|
+
project_path &&
|
|
48
|
+
incoming_email_token &&
|
|
49
|
+
!incoming_email_token.include?('+') &&
|
|
50
|
+
!mail_key.include?(ReplyKey::UNSUBSCRIBE_SUFFIX_LEGACY)
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'base'
|
|
4
|
+
|
|
5
|
+
module Gitlab
|
|
6
|
+
module EmailHandler
|
|
7
|
+
module Matchers
|
|
8
|
+
# Mirrors Gitlab::Email::Handler::CreateMergeRequestHandler.
|
|
9
|
+
# incoming+gitlab-org-gitlab-ce-20-Author_Token-merge-request@incoming.gitlab.com
|
|
10
|
+
# incoming+gitlab-org/gitlab-ce+merge-request+Author_Token@incoming.gitlab.com (legacy)
|
|
11
|
+
class CreateMergeRequest < Base
|
|
12
|
+
HANDLER_REGEX = /\A#{ReplyKey::HANDLER_ACTION_BASE_REGEX}-(?<incoming_email_token>.+)-merge-request\z/
|
|
13
|
+
HANDLER_REGEX_LEGACY = /\A(?<project_path>[^+]*)\+merge-request\+(?<incoming_email_token>.*)/
|
|
14
|
+
|
|
15
|
+
def match(mail_key)
|
|
16
|
+
key = mail_key.to_s
|
|
17
|
+
|
|
18
|
+
matched = HANDLER_REGEX.match(key) unless mail_key&.include?('/')
|
|
19
|
+
if matched
|
|
20
|
+
return identification(
|
|
21
|
+
project_slug: matched[:project_slug],
|
|
22
|
+
project_id: matched[:project_id]&.to_i,
|
|
23
|
+
incoming_email_token: matched[:incoming_email_token]
|
|
24
|
+
)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
matched = HANDLER_REGEX_LEGACY.match(key)
|
|
28
|
+
return unless matched
|
|
29
|
+
|
|
30
|
+
identification(
|
|
31
|
+
project_path: matched[:project_path],
|
|
32
|
+
incoming_email_token: matched[:incoming_email_token]
|
|
33
|
+
)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def handler_name
|
|
37
|
+
:create_merge_request
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'base'
|
|
4
|
+
|
|
5
|
+
module Gitlab
|
|
6
|
+
module EmailHandler
|
|
7
|
+
module Matchers
|
|
8
|
+
# Mirrors Gitlab::Email::Handler::CreateNoteHandler.
|
|
9
|
+
# incoming+<reply_key>@incoming.gitlab.com
|
|
10
|
+
class CreateNote < Base
|
|
11
|
+
HANDLER_REGEX = /\A#{ReplyKey::FULL_REPLY_KEY_REGEX}\z/
|
|
12
|
+
|
|
13
|
+
def match(mail_key)
|
|
14
|
+
matched = HANDLER_REGEX.match(mail_key.to_s)
|
|
15
|
+
return unless matched
|
|
16
|
+
|
|
17
|
+
identification(
|
|
18
|
+
reply_key: matched[:reply_key],
|
|
19
|
+
namespace_id: matched[:namespace_id],
|
|
20
|
+
legacy_key: matched[:legacy_key]
|
|
21
|
+
)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def handler_name
|
|
25
|
+
:create_note
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'base'
|
|
4
|
+
|
|
5
|
+
module Gitlab
|
|
6
|
+
module EmailHandler
|
|
7
|
+
module Matchers
|
|
8
|
+
# Mirrors Gitlab::Email::Handler::CreateNoteOnIssuableHandler.
|
|
9
|
+
# incoming+gitlab-org-gitlab-ce-20-Author_Token-issue-34@incoming.gitlab.com
|
|
10
|
+
class CreateNoteOnIssuable < Base
|
|
11
|
+
HANDLER_REGEX =
|
|
12
|
+
/\A#{ReplyKey::HANDLER_ACTION_BASE_REGEX}-(?<incoming_email_token>.+)-issue-(?<issuable_iid>\d+)\z/
|
|
13
|
+
|
|
14
|
+
def match(mail_key)
|
|
15
|
+
matched = HANDLER_REGEX.match(mail_key.to_s)
|
|
16
|
+
return unless matched
|
|
17
|
+
|
|
18
|
+
identification(
|
|
19
|
+
project_slug: matched[:project_slug],
|
|
20
|
+
project_id: matched[:project_id]&.to_i,
|
|
21
|
+
incoming_email_token: matched[:incoming_email_token],
|
|
22
|
+
issuable_iid: matched[:issuable_iid]&.to_i
|
|
23
|
+
)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def handler_name
|
|
27
|
+
:create_note_on_issuable
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'base'
|
|
4
|
+
|
|
5
|
+
module Gitlab
|
|
6
|
+
module EmailHandler
|
|
7
|
+
module Matchers
|
|
8
|
+
# Mirrors Gitlab::Email::Handler::ServiceDeskHandler (parsing portion).
|
|
9
|
+
# incoming+gitlab-org-gitlab-ce-20-issue-@incoming.gitlab.com
|
|
10
|
+
# incoming+gitlab-org/gitlab-ce@incoming.gitlab.com (legacy)
|
|
11
|
+
# support+gitlab-org-gitlab-ce-abc123_def@service-desk.gitlab.com (project key)
|
|
12
|
+
#
|
|
13
|
+
# The opaque project key form (slug-key) is stored whole in
|
|
14
|
+
# `service_desk_settings.project_key_address_slug` and claimed as unique
|
|
15
|
+
# in the Topology Service, so it is identifiable without a database
|
|
16
|
+
# lookup. Resolving which project it belongs to remains an app-side
|
|
17
|
+
# concern (Project.with_service_desk_key). The custom email form is
|
|
18
|
+
# resolvable via a Topology Service lookup.
|
|
19
|
+
class ServiceDesk < Base
|
|
20
|
+
HANDLER_REGEX = /\A#{ReplyKey::HANDLER_ACTION_BASE_REGEX}-issue-\z/
|
|
21
|
+
HANDLER_REGEX_LEGACY = /\A(?<project_path>[^+]*)\z/
|
|
22
|
+
# Parses an opaque service desk key of the form `<slug>-<key>`. The key
|
|
23
|
+
# is resolved against the database (Project.with_service_desk_key) so
|
|
24
|
+
# this stays an app-side concern, but the pattern itself lives here to
|
|
25
|
+
# keep all email key parsing in one place.
|
|
26
|
+
PROJECT_KEY_PATTERN = /\A(?<slug>.+)-(?<key>[a-z0-9_]+)\z/
|
|
27
|
+
|
|
28
|
+
def match(mail_key)
|
|
29
|
+
key = mail_key.to_s
|
|
30
|
+
|
|
31
|
+
matched = HANDLER_REGEX.match(key) unless mail_key&.include?('/')
|
|
32
|
+
if matched
|
|
33
|
+
return identification(
|
|
34
|
+
project_slug: matched[:project_slug],
|
|
35
|
+
project_id: matched[:project_id]&.to_i
|
|
36
|
+
)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
matched = HANDLER_REGEX_LEGACY.match(key)
|
|
40
|
+
if matched && can_handle_legacy_format?(matched[:project_path], key)
|
|
41
|
+
return identification(project_path: matched[:project_path])
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
match_project_key(key)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def handler_name
|
|
48
|
+
:service_desk
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
private
|
|
52
|
+
|
|
53
|
+
def can_handle_legacy_format?(project_path, mail_key)
|
|
54
|
+
project_path&.include?('/') && !mail_key.include?('+')
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# An opaque `<slug>-<key>` project key. The whole concatenated string is
|
|
58
|
+
# what `service_desk_settings.project_key_address_slug` stores and the
|
|
59
|
+
# Topology Service claims, so no splitting is needed: both parts are
|
|
60
|
+
# already lowercase by validation, and the composite value is matched
|
|
61
|
+
# verbatim.
|
|
62
|
+
def match_project_key(key)
|
|
63
|
+
return unless PROJECT_KEY_PATTERN.match?(key)
|
|
64
|
+
|
|
65
|
+
identification(project_key_address_slug: key)
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'base'
|
|
4
|
+
|
|
5
|
+
module Gitlab
|
|
6
|
+
module EmailHandler
|
|
7
|
+
module Matchers
|
|
8
|
+
# Mirrors Gitlab::Email::Handler::UnsubscribeHandler.
|
|
9
|
+
# incoming+<reply_key>-unsubscribe@incoming.gitlab.com
|
|
10
|
+
# incoming+<reply_key>+unsubscribe@incoming.gitlab.com (legacy)
|
|
11
|
+
class Unsubscribe < Base
|
|
12
|
+
HANDLER_REGEX_FOR = ->(suffix) {
|
|
13
|
+
/\A(?<reply_token>#{ReplyKey::FULL_REPLY_KEY_REGEX})#{Regexp.escape(suffix)}\z/
|
|
14
|
+
}.freeze
|
|
15
|
+
HANDLER_REGEX = HANDLER_REGEX_FOR.call(ReplyKey::UNSUBSCRIBE_SUFFIX).freeze
|
|
16
|
+
HANDLER_REGEX_LEGACY = HANDLER_REGEX_FOR.call(ReplyKey::UNSUBSCRIBE_SUFFIX_LEGACY).freeze
|
|
17
|
+
|
|
18
|
+
def match(mail_key)
|
|
19
|
+
key = mail_key.to_s
|
|
20
|
+
matched = HANDLER_REGEX.match(key) || HANDLER_REGEX_LEGACY.match(key)
|
|
21
|
+
return unless matched
|
|
22
|
+
|
|
23
|
+
identification(
|
|
24
|
+
reply_token: matched[:reply_token],
|
|
25
|
+
reply_key: matched[:reply_key],
|
|
26
|
+
namespace_id: matched[:namespace_id],
|
|
27
|
+
legacy_key: matched[:legacy_key]
|
|
28
|
+
)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def handler_name
|
|
32
|
+
:unsubscribe
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Gitlab
|
|
4
|
+
module EmailHandler
|
|
5
|
+
# Single source of truth for the regular expressions and constants used to
|
|
6
|
+
# parse incoming email keys. These were previously defined inline in
|
|
7
|
+
# `SentNotification` and the email handlers. Keeping them here lets both the
|
|
8
|
+
# GitLab application and the standalone mail_room identification service
|
|
9
|
+
# share the exact same parsing rules.
|
|
10
|
+
module ReplyKey
|
|
11
|
+
REPLY_KEY_BYTE_SIZE = 16
|
|
12
|
+
INTEGER_CONVERT_BASE = 36
|
|
13
|
+
BASE36_REGEX = /[0-9a-z]/
|
|
14
|
+
NAMESPACE_REGEX = /(?:-(?<namespace_id>#{BASE36_REGEX}{1,13}))?/
|
|
15
|
+
# Email reply key is in the form: <base36-partition-id>-<base36-reply-key>-<base36-namespace-id>
|
|
16
|
+
PARTITIONED_REPLY_KEY_REGEX =
|
|
17
|
+
/(?<partition>#{BASE36_REGEX}{1,4})-(?<reply_key>#{BASE36_REGEX}{25})#{NAMESPACE_REGEX}/
|
|
18
|
+
LEGACY_REPLY_KEY_REGEX = /(?<legacy_key>[a-f\d]{32})/
|
|
19
|
+
FULL_REPLY_KEY_REGEX = /(?:(#{LEGACY_REPLY_KEY_REGEX})|(#{PARTITIONED_REPLY_KEY_REGEX}))/
|
|
20
|
+
|
|
21
|
+
# Base regex shared by handlers that encode `<project_slug>-<project_id>` in
|
|
22
|
+
# the mail key.
|
|
23
|
+
HANDLER_ACTION_BASE_REGEX = /(?<project_slug>.+)-(?<project_id>\d+)/
|
|
24
|
+
|
|
25
|
+
UNSUBSCRIBE_SUFFIX = '-unsubscribe'
|
|
26
|
+
UNSUBSCRIBE_SUFFIX_LEGACY = '+unsubscribe'
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Gitlab
|
|
4
|
+
module EmailHandler
|
|
5
|
+
# The identified target of an incoming email: the kind of resource that owns
|
|
6
|
+
# it and the value that locates it. This is a plain value object with no
|
|
7
|
+
# knowledge of how the target is later resolved or routed.
|
|
8
|
+
#
|
|
9
|
+
# `kind` is one of:
|
|
10
|
+
# :project_id, :namespace_id, :route, :service_desk_custom_email,
|
|
11
|
+
# :service_desk_project_key_address_slug
|
|
12
|
+
Target = Data.define(:kind, :value) do
|
|
13
|
+
def self.project_id(value)
|
|
14
|
+
new(kind: :project_id, value: value)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def self.namespace_id(value)
|
|
18
|
+
new(kind: :namespace_id, value: value)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def self.route(path)
|
|
22
|
+
new(kind: :route, value: path.to_s.split('/').first)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def self.service_desk_custom_email(value)
|
|
26
|
+
new(kind: :service_desk_custom_email, value: value)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def self.service_desk_project_key_address_slug(value)
|
|
30
|
+
new(kind: :service_desk_project_key_address_slug, value: value)
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'email_handler/version'
|
|
4
|
+
|
|
5
|
+
# Pure, dependency-free incoming email identification: regex parsing of mail
|
|
6
|
+
# keys into a Target. No Rails, gRPC or HTTP dependencies. Resolving a Target to
|
|
7
|
+
# a cell and forwarding the email are the responsibility of the consumer (the
|
|
8
|
+
# mail_room service), not this gem.
|
|
9
|
+
require_relative 'email_handler/reply_key'
|
|
10
|
+
require_relative 'email_handler/target'
|
|
11
|
+
require_relative 'email_handler/identification'
|
|
12
|
+
require_relative 'email_handler/identifier'
|
|
13
|
+
require_relative 'email_handler/custom_email'
|
|
14
|
+
require_relative 'email_handler/mail_key'
|
metadata
CHANGED
|
@@ -1,14 +1,57 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: gitlab-email_handler
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.1.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
|
-
- group::
|
|
7
|
+
- group::project management
|
|
8
|
+
autorequire:
|
|
8
9
|
bindir: bin
|
|
9
10
|
cert_chain: []
|
|
10
|
-
date:
|
|
11
|
-
dependencies:
|
|
11
|
+
date: 2026-10-19 00:00:00.000000000 Z
|
|
12
|
+
dependencies:
|
|
13
|
+
- !ruby/object:Gem::Dependency
|
|
14
|
+
name: gitlab-styles
|
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
|
16
|
+
requirements:
|
|
17
|
+
- - "~>"
|
|
18
|
+
- !ruby/object:Gem::Version
|
|
19
|
+
version: '14.0'
|
|
20
|
+
type: :development
|
|
21
|
+
prerelease: false
|
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
23
|
+
requirements:
|
|
24
|
+
- - "~>"
|
|
25
|
+
- !ruby/object:Gem::Version
|
|
26
|
+
version: '14.0'
|
|
27
|
+
- !ruby/object:Gem::Dependency
|
|
28
|
+
name: mail
|
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
|
30
|
+
requirements:
|
|
31
|
+
- - "~>"
|
|
32
|
+
- !ruby/object:Gem::Version
|
|
33
|
+
version: '2.8'
|
|
34
|
+
type: :development
|
|
35
|
+
prerelease: false
|
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
37
|
+
requirements:
|
|
38
|
+
- - "~>"
|
|
39
|
+
- !ruby/object:Gem::Version
|
|
40
|
+
version: '2.8'
|
|
41
|
+
- !ruby/object:Gem::Dependency
|
|
42
|
+
name: rspec
|
|
43
|
+
requirement: !ruby/object:Gem::Requirement
|
|
44
|
+
requirements:
|
|
45
|
+
- - "~>"
|
|
46
|
+
- !ruby/object:Gem::Version
|
|
47
|
+
version: '3.0'
|
|
48
|
+
type: :development
|
|
49
|
+
prerelease: false
|
|
50
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
51
|
+
requirements:
|
|
52
|
+
- - "~>"
|
|
53
|
+
- !ruby/object:Gem::Version
|
|
54
|
+
version: '3.0'
|
|
12
55
|
description: Parses incoming email keys to identify the target handler and the resource
|
|
13
56
|
(project, namespace, or route) that owns the email.
|
|
14
57
|
email:
|
|
@@ -17,12 +60,29 @@ executables: []
|
|
|
17
60
|
extensions: []
|
|
18
61
|
extra_rdoc_files: []
|
|
19
62
|
files:
|
|
20
|
-
- lib/gitlab
|
|
21
|
-
|
|
63
|
+
- lib/gitlab/email_handler.rb
|
|
64
|
+
- lib/gitlab/email_handler/custom_email.rb
|
|
65
|
+
- lib/gitlab/email_handler/identification.rb
|
|
66
|
+
- lib/gitlab/email_handler/identifier.rb
|
|
67
|
+
- lib/gitlab/email_handler/mail_key.rb
|
|
68
|
+
- lib/gitlab/email_handler/matchers/base.rb
|
|
69
|
+
- lib/gitlab/email_handler/matchers/create_issue.rb
|
|
70
|
+
- lib/gitlab/email_handler/matchers/create_merge_request.rb
|
|
71
|
+
- lib/gitlab/email_handler/matchers/create_note.rb
|
|
72
|
+
- lib/gitlab/email_handler/matchers/create_note_on_issuable.rb
|
|
73
|
+
- lib/gitlab/email_handler/matchers/service_desk.rb
|
|
74
|
+
- lib/gitlab/email_handler/matchers/unsubscribe.rb
|
|
75
|
+
- lib/gitlab/email_handler/reply_key.rb
|
|
76
|
+
- lib/gitlab/email_handler/target.rb
|
|
77
|
+
- lib/gitlab/email_handler/version.rb
|
|
78
|
+
homepage: https://gitlab.com/gitlab-org/ruby/gems/gitlab-email_handler
|
|
22
79
|
licenses:
|
|
23
80
|
- MIT
|
|
24
81
|
metadata:
|
|
25
82
|
rubygems_mfa_required: 'true'
|
|
83
|
+
source_code_uri: https://gitlab.com/gitlab-org/ruby/gems/gitlab-email_handler
|
|
84
|
+
changelog_uri: https://gitlab.com/gitlab-org/ruby/gems/gitlab-email_handler/-/releases
|
|
85
|
+
post_install_message:
|
|
26
86
|
rdoc_options: []
|
|
27
87
|
require_paths:
|
|
28
88
|
- lib
|
|
@@ -37,7 +97,8 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
37
97
|
- !ruby/object:Gem::Version
|
|
38
98
|
version: '0'
|
|
39
99
|
requirements: []
|
|
40
|
-
rubygems_version: 3.
|
|
100
|
+
rubygems_version: 3.5.22
|
|
101
|
+
signing_key:
|
|
41
102
|
specification_version: 4
|
|
42
103
|
summary: Incoming email identification for GitLab
|
|
43
104
|
test_files: []
|
data/lib/gitlab-email_handler.rb
DELETED