|
| 1 | +# frozen_string_literal: true |
| 2 | + |
| 3 | +require "base64" |
| 4 | + |
| 5 | +class Net::IMAP::FakeServer |
| 6 | + |
| 7 | + # :nodoc: |
| 8 | + class CommandRouter |
| 9 | + module Routable |
| 10 | + def on(*command_names, &handler) |
| 11 | + scope = self.is_a?(Module) ? self : singleton_class |
| 12 | + command_names.each do |command_name| |
| 13 | + scope.define_method("handle_#{command_name.downcase}", &handler) |
| 14 | + end |
| 15 | + end |
| 16 | + end |
| 17 | + |
| 18 | + include Routable |
| 19 | + extend Routable |
| 20 | + |
| 21 | + def initialize(writer, config:, state:) |
| 22 | + @config = config |
| 23 | + @state = state |
| 24 | + @writer = writer |
| 25 | + end |
| 26 | + |
| 27 | + def commands; state.commands end |
| 28 | + |
| 29 | + def handle(command) |
| 30 | + commands << command |
| 31 | + resp = @writer.for_command(command) |
| 32 | + handler = handler_for(command) or return resp.fail_no_command |
| 33 | + handler.call(resp) |
| 34 | + end |
| 35 | + alias << handle |
| 36 | + |
| 37 | + def handler_for(command) |
| 38 | + hname = command.name.downcase.to_sym |
| 39 | + mname = :"handle_#{hname}" |
| 40 | + config.handlers[hname] || (method(mname) if respond_to?(mname)) |
| 41 | + end |
| 42 | + |
| 43 | + on "CAPABILITY" do |resp| |
| 44 | + resp.args.nil? or return resp.fail_bad_args |
| 45 | + resp.untagged :CAPABILITY, state.capabilities(config) |
| 46 | + resp.done_ok |
| 47 | + end |
| 48 | + |
| 49 | + on "NOOP" do |resp| |
| 50 | + resp.args.nil? or return resp.fail_bad_args |
| 51 | + resp.done_ok |
| 52 | + end |
| 53 | + |
| 54 | + on "LOGOUT" do |resp| |
| 55 | + resp.args.nil? or return resp.fail_bad_args |
| 56 | + resp.bye |
| 57 | + state.logout |
| 58 | + resp.done_ok |
| 59 | + end |
| 60 | + |
| 61 | + on "STARTTLS" do |resp| |
| 62 | + state.tls? and return resp.fail_bad_args "TLS already established" |
| 63 | + state.not_authenticated? or return resp.fail_bad_state(state) |
| 64 | + resp.done_ok |
| 65 | + state.use_tls |
| 66 | + end |
| 67 | + |
| 68 | + on "LOGIN" do |resp| |
| 69 | + state.not_authenticated? or return resp.fail_bad_state(state) |
| 70 | + args = resp.command.args |
| 71 | + args.count == 2 or return resp.fail_bad_args |
| 72 | + username, password = args |
| 73 | + username == config.user[:username] or return resp.fail_no "wrong username" |
| 74 | + password == config.user[:password] or return resp.fail_no "wrong password" |
| 75 | + state.authenticate config.user |
| 76 | + resp.done_ok |
| 77 | + end |
| 78 | + |
| 79 | + on "AUTHENTICATE" do |resp| |
| 80 | + state.not_authenticated? or return resp.fail_bad_state(state) |
| 81 | + args = resp.command.args |
| 82 | + args == "PLAIN" or return resp.fail_no "unsupported" |
| 83 | + response_b64 = resp.request_continuation("") || "" |
| 84 | + response = Base64.decode64(response_b64) |
| 85 | + response.empty? and return resp.fail_bad "canceled" |
| 86 | + # TODO: support mechanisms other than PLAIN. |
| 87 | + parts = response.split("\0") |
| 88 | + parts.length == 3 or return resp.fail_bad "invalid" |
| 89 | + authzid, authcid, password = parts |
| 90 | + authzid = authcid if authzid.empty? |
| 91 | + authzid == config.user[:username] or return resp.fail_no "wrong username" |
| 92 | + authcid == config.user[:username] or return resp.fail_no "wrong username" |
| 93 | + password == config.user[:password] or return resp.fail_no "wrong password" |
| 94 | + state.authenticate config.user |
| 95 | + resp.done_ok |
| 96 | + end |
| 97 | + |
| 98 | + on "ENABLE" do |resp| |
| 99 | + state.authenticated? or return resp.fail_bad_state(state) |
| 100 | + resp.args&.any? or return resp.fail_bad_args |
| 101 | + enabled = (resp.args & config.capabilities_enablable) - state.enabled |
| 102 | + state.enabled.concat enabled |
| 103 | + resp.untagged :ENABLED, enabled |
| 104 | + resp.done_ok |
| 105 | + end |
| 106 | + |
| 107 | + # Will be used as defaults for mailboxes that haven't set their own values |
| 108 | + RFC3501_6_3_1_SELECT_EXAMPLE_DATA = { |
| 109 | + exists: 172, |
| 110 | + recent: 1, |
| 111 | + unseen: 12, |
| 112 | + uidvalidity: 3857529045, |
| 113 | + uidnext: 4392, |
| 114 | + |
| 115 | + flags: %i[Answered Flagged Deleted Seen Draft].freeze, |
| 116 | + permanentflags: %i[Deleted Seen *].freeze, |
| 117 | + }.freeze |
| 118 | + |
| 119 | + on "SELECT" do |resp| |
| 120 | + state.user or return resp.fail_bad_state(state) |
| 121 | + name, args = resp.args |
| 122 | + name or return resp.fail_bad_args |
| 123 | + name = name.upcase if name.to_s.casecmp? "inbox" |
| 124 | + mbox = config.mailboxes[name] |
| 125 | + mbox or return resp.fail_no "invalid mailbox" |
| 126 | + state.select mbox: mbox, args: args |
| 127 | + attrs = RFC3501_6_3_1_SELECT_EXAMPLE_DATA.merge mbox.to_h |
| 128 | + resp.untagged "%{exists} EXISTS" % attrs |
| 129 | + resp.untagged "%{recent} RECENT" % attrs |
| 130 | + resp.untagged "OK [UNSEEN %{unseen}] ..." % attrs |
| 131 | + resp.untagged "OK [UIDVALIDITY %{uidvalidity}] UIDs valid" % attrs |
| 132 | + resp.untagged "OK [UIDNEXT %{uidnext}] Predicted next UID" % attrs |
| 133 | + if mbox[:uidnotsticky] |
| 134 | + resp.untagged "NO [UIDNOTSTICKY] Non-persistent UIDs" |
| 135 | + end |
| 136 | + resp.untagged "FLAGS (%s)" % [flags(attrs[:flags])] |
| 137 | + resp.untagged "OK [PERMANENTFLAGS (%s)] Limited" % [ |
| 138 | + flags(attrs[:permanentflags]) |
| 139 | + ] |
| 140 | + resp.done_ok code: "READ-WRITE" |
| 141 | + end |
| 142 | + |
| 143 | + on "CLOSE", "UNSELECT" do |resp| |
| 144 | + resp.args.nil? or return resp.fail_bad_args |
| 145 | + state.unselect |
| 146 | + resp.done_ok |
| 147 | + end |
| 148 | + |
| 149 | + private |
| 150 | + |
| 151 | + attr_reader :config, :state |
| 152 | + |
| 153 | + def flags(flags) |
| 154 | + flags.map { [Symbol === _1 ? "\\" : "", _1].join }.join(" ") |
| 155 | + end |
| 156 | + |
| 157 | + end |
| 158 | +end |
| 159 | + |
0 commit comments