From 0f568bcf355ae7ffa7cfc95dd33b6fa424dd738b Mon Sep 17 00:00:00 2001 From: shugo Date: Fri, 13 Oct 2000 08:06:16 +0000 Subject: lib/net/imap.rb git-svn-id: svn+ssh://ci.ruby-lang.org/ruby/trunk@1000 b2dd03c8-39d4-4d8f-98ff-823fe69b080e --- lib/net/imap.rb | 1087 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 1087 insertions(+) create mode 100644 lib/net/imap.rb (limited to 'lib/net/imap.rb') diff --git a/lib/net/imap.rb b/lib/net/imap.rb new file mode 100644 index 0000000000..3bc3750e12 --- /dev/null +++ b/lib/net/imap.rb @@ -0,0 +1,1087 @@ +=begin + += net/imap.rb + +Copyright (C) 2000 Shugo Maeda + +This library is distributed under the terms of the Ruby license. +You can freely distribute/modify this library. + +== class Net::IMAP + +Net::IMAP implements Internet Message Access Protocol (IMAP) clients. + +=== Super Class + +Object + +=== Class Methods + +: new(host, port = 143) + Creates a new Net::IMAP object and connects it to the specified + port on the named host. + +: debug + Returns the debug mode + +: debug = val + Sets the debug mode + +=== Methods + +: greeting + Returns an initial greeting response from the server. + +: responses + Returns recorded untagged responses. + + ex). + imap.select("inbox") + p imap.responses["EXISTS"][-1] + #=> [2] + p imap.responses["UIDVALIDITY"][-1] + #=> [968263756] + +: disconnect + Disconnects from the server. + +: capability + Sends a CAPABILITY command, and returns a listing of + capabilities that the server supports. + +: noop + Sends a NOOP command to the server. It does nothing. + +: logout + Sends a LOGOUT command to inform the server that the client is + done with the connection. + +: authenticate(auth_type, arg...) + Sends an AUTEHNTICATE command to authenticate the client. + The auth_type parameter is a string that represents + the authentication mechanism to be used. Currently Net::IMAP + supports "LOGIN" and "CRAM-MD5" for the auth_type. + + ex). + imap.authenticate('LOGIN', user, password) + +: login(user, password) + Sends a LOGIN command to identify the client and carries + the plaintext password authenticating this user. + +: select(mailbox) + Sends a SELECT command to select a mailbox so that messages + in the mailbox can be accessed. + +: examine(mailbox) + Sends a EXAMINE command to select a mailbox so that messages + in the mailbox can be accessed. However, the selected mailbox + is identified as read-only. + +: create(mailbox) + Sends a CREATE command to create a new mailbox. + +: delete(mailbox) + Sends a DELETE command to remove the mailbox. + +: rename(mailbox, newname) + Sends a RENAME command to change the name of the mailbox to + the newname. + +: subscribe(mailbox) + Sends a SUBSCRIBE command to add the specified mailbox name to + the server's set of "active" or "subscribed" mailboxes. + +: unsubscribe(mailbox) + Sends a UNSUBSCRIBE command to remove the specified mailbox name + from the server's set of "active" or "subscribed" mailboxes. + +: list(refname, mailbox) + Sends a LIST command, and returns a subset of names from + the complete set of all names available to the client. + + ex). + imap.create("foo/bar") + imap.create("foo/baz") + p imap.list("", "foo/%") + #=> [[[:NoSelect], "/", "foo/"], [[:NoInferiors], "/", "foo/baz"], [[:NoInferiors], "/", "foo/bar"]] + +: lsub(refname, mailbox) + Sends a LSUB command, and returns a subset of names from the set + of names that the user has declared as being "active" or + "subscribed". + +: status(mailbox, attr) + Sends a STATUS command, and returns the status of the indicated + mailbox. + + ex). + p imap.status("inbox", ["MESSAGES", "RECENT"]) + #=> {"RECENT"=>0, "MESSAGES"=>5} + +: append(mailbox, message, flags = nil, date_time = nil) + Sends a APPEND command to append the message to the end of + the mailbox. + + ex). + imap.append("inbox", < [1, 6, 7, 8] + +: fetch(set, attr) +: uid_fetch(set, attr) + Sends a FETCH command to retrieve data associated with a message + in the mailbox. the set parameter is a number or an array of + numbers or a Range object. the number is a message sequence + number (fetch) or a unique identifier (uid_fetch). + + ex). + p imap.fetch(6..-1, "UID") + #=> [[6, {"UID"=>28}], [7, {"UID"=>29}], [8, {"UID"=>30}]] + +: store(set, attr, flags) +: uid_store(set, attr, flags) + Sends a STORE command to alter data associated with a message + in the mailbox. the set parameter is a number or an array of + numbers or a Range object. the number is a message sequence + number (store) or a unique identifier (uid_store). + + ex). + p imap.store(6..-1, "+FLAGS", [:Deleted]) + #=> [[6, {"FLAGS"=>[:Deleted]}], [7, {"FLAGS"=>[:Seen, :Deleted]}], [8, {"FLAGS"=>[:Seen, :Deleted]}]] + +: copy(set, mailbox) +: uid_copy(set, mailbox) + Sends a COPY command to copy the specified message(s) to the end + of the specified destination mailbox. the set parameter is + a number or an array of numbers or a Range object. the number is + a message sequence number (copy) or a unique identifier (uid_copy). + +: sort(sort_keys, search_keys, charset) +: uid_sort(sort_keys, search_keys, charset) + Sends a SORT command to sort messages in the mailbox. + + ex). + p imap.sort(["FROM"], ["ALL"], "US-ASCII") + #=> [1, 2, 3, 5, 6, 7, 8, 4, 9] + p imap.sort(["DATE"], ["SUBJECT", "hello"], "US-ASCII") + #=> [6, 7, 8, 1] + +=end + +require "socket" +require "md5" + +module Net + class IMAP + attr_reader :greeting, :responses + + def self.debug + return @@debug + end + + def self.debug=(val) + return @@debug = val + end + + def disconnect + @sock.close + end + + def capability + send_command("CAPABILITY") + return @responses.delete("CAPABILITY")[-1] + end + + def noop + send_command("NOOP") + end + + def logout + send_command("LOGOUT") + end + + def authenticate(auth_type, *args) + auth_type = auth_type.upcase + unless AUTHENTICATORS.has_key?(auth_type) + raise ArgumentError, + format('unknown auth type - "%s"', auth_type) + end + authenticator = AUTHENTICATORS[auth_type].new(*args) + send_command("AUTHENTICATE", auth_type) do |resp| + if resp.prefix == "+" + data = authenticator.process(resp[0].unpack("m")[0]) + send_data([data].pack("m").chomp) + end + end + end + + def login(user, password) + send_command("LOGIN", user, password) + end + + def select(mailbox) + @responses.clear + send_command("SELECT", mailbox) + end + + def examine(mailbox) + @responses.clear + send_command("EXAMINE", mailbox) + end + + def create(mailbox) + send_command("CREATE", mailbox) + end + + def delete(mailbox) + send_command("DELETE", mailbox) + end + + def rename(mailbox, newname) + send_command("RENAME", mailbox, newname) + end + + def subscribe(mailbox) + send_command("SUBSCRIBE", mailbox) + end + + def unsubscribe(mailbox) + send_command("UNSUBSCRIBE", mailbox) + end + + def list(refname, mailbox) + send_command("LIST", refname, mailbox) + return @responses.delete("LIST") + end + + def lsub(refname, mailbox) + send_command("LSUB", refname, mailbox) + return @responses.delete("LSUB") + end + + def status(mailbox, attr) + send_command("STATUS", mailbox, attr) + status_list = @responses.delete("STATUS")[-1][1] + return Hash[*status_list] + end + + def append(mailbox, message, flags = nil, date_time = nil) + args = [] + if flags + flags.collect! {|i| Flag.new(i)} + args.push(flags) + end + args.push(date_time) if date_time + args.push(Literal.new(message)) + send_command("APPEND", mailbox, *args) + end + + def check + send_command("CHECK") + end + + def close + send_command("CLOSE") + end + + def expunge + send_command("EXPUNGE") + return @responses.delete("EXPUNGE").collect {|i| i[0]} + end + + def search(keys, charset = nil) + return search_internal("SEARCH", keys, charset) + end + + def uid_search(keys, charset = nil) + return search_internal("UID SEARCH", keys, charset) + end + + def fetch(set, attr) + return fetch_internal("FETCH", set, attr) + end + + def uid_fetch(set, attr) + return fetch_internal("UID FETCH", set, attr) + end + + def store(set, attr, flags) + return store_internal("STORE", set, attr, flags) + end + + def uid_store(set, attr, flags) + return store_internal("UID STORE", set, attr, flags) + end + + def copy(set, mailbox) + copy_internal("COPY", set, mailbox) + end + + def uid_copy(set, mailbox) + copy_internal("UID COPY", set, mailbox) + end + + def sort(sort_keys, search_keys, charset) + return sort_internal("SORT", sort_keys, search_keys, charset) + end + + def uid_sort(sort_keys, search_keys, charset) + return sort_internal("UID SORT", sort_keys, search_keys, charset) + end + + private + + CRLF = "\r\n" + PORT = 143 + + @@debug = false + + def initialize(host, port = PORT) + @host = host + @port = port + @tag_prefix = "RUBY" + @tagno = 0 + @parser = ResponseParser.new + @sock = TCPSocket.open(host, port) + @responses = Hash.new([].freeze) + @greeting = get_response + if @greeting.name == "BYE" + @sock.close + raise ByeResponseError, resp[0] + end + end + + def send_command(cmd, *args, &block) + tag = generate_tag + data = args.collect {|i| format_data(i)}.join(" ") + if data.length > 0 + put_line(tag + " " + cmd + " " + data) + else + put_line(tag + " " + cmd) + end + return get_all_responses(tag, cmd, &block) + end + + def generate_tag + @tagno += 1 + return format("%s%04d", @tag_prefix, @tagno) + end + + def send_data(*args) + data = args.collect {|i| format_data(i)}.join(" ") + put_line(data) + end + + def put_line(line) + line = line + CRLF + @sock.print(line) + if @@debug + $stderr.print(line.gsub(/^/, "C: ")) + end + end + + def get_all_responses(tag, cmd, &block) + while resp = get_response + if @@debug + $stderr.puts(resp.inspect) + end + if resp.prefix == tag + case resp.name + when "NO" + raise NoResponseError, resp[0] + when "BAD" + raise BadResponseError, resp[0] + else + return resp + end + else + if resp.prefix == "*" + if resp.name == "BYE" && + cmd != "LOGOUT" + raise ByeResponseError, resp[0] + end + record_response(resp.name, resp.data) + if /\A(OK|NO|BAD)\z/ =~ resp.name && + resp[0].instance_of?(Array) + record_response(resp[0][0], resp[0][1..-1]) + end + end + block.call(resp) if block + end + end + end + + def get_response + buff = "" + while true + s = @sock.gets(CRLF) + break unless s + buff.concat(s) + if /\{(\d+)\}\r\n/ =~ s + s = @sock.read($1.to_i) + buff.concat(s) + else + break + end + end + return nil if buff.length == 0 + if @@debug + $stderr.print(buff.gsub(/^/, "S: ")) + end + return @parser.parse(buff) + end + + def record_response(name, data) + unless @responses.has_key?(name) + @responses[name] = [] + end + @responses[name].push(data) + end + + def format_data(data) + case data + when nil + return "NIL" + when String + return format_string(data) + when Integer + return format_number(data) + when Array + return format_list(data) + when Time + return format_time(data) + when Symbol + return format_symbol(data) + else + return data.format_data + end + end + + def format_string(str) + case str + when "" + return '""' + when /[\r\n]/ + # literal + return "{" + str.length.to_s + "}" + CRLF + str + when /[(){ \x00-\x1f\x7f%*"\\]/ + # quoted string + return '"' + str.gsub(/["\\]/, "\\\\\\&") + '"' + else + # atom + return str + end + end + + def format_number(num) + if num < 0 || num >= 4294967296 + raise DataFormatError, num.to_s + end + return num.to_s + end + + def format_list(list) + contents = list.collect {|i| format_data(i)}.join(" ") + return "(" + contents + ")" + end + + DATE_MONTH = %w(Jan Feb Mar Apr May Jun Jul Aug Sep Oct Nov Dec) + + def format_time(time) + t = time.dup.gmtime + return format('"%2d-%3s-%4d %02d:%02d:%02d +0000"', + t.day, DATE_MONTH[t.month - 1], t.year, + t.hour, t.min, t.sec) + end + + def format_symbol(symbol) + return "\\" + symbol.to_s + end + + def search_internal(cmd, keys, charset) + normalize_searching_criteria(keys) + if charset + send_command(cmd, "CHARSET", charset, *keys) + else + send_command(cmd, *keys) + end + return @responses.delete("SEARCH")[-1] + end + + def fetch_internal(cmd, set, attr) + send_command(cmd, MessageSet.new(set), attr) + return get_fetch_response + end + + def store_internal(cmd, set, attr, flags) + send_command(cmd, MessageSet.new(set), attr, flags) + return get_fetch_response + end + + def copy_internal(cmd, set, mailbox) + send_command(cmd, MessageSet.new(set), mailbox) + end + + def sort_internal(cmd, sort_keys, search_keys, charset) + normalize_searching_criteria(search_keys) + send_command(cmd, sort_keys, charset, *search_keys) + return @responses.delete("SORT")[-1] + end + + def normalize_searching_criteria(keys) + keys.collect! do |i| + case i + when -1, Range, Array + MessageSet.new(i) + else + i + end + end + end + + def get_fetch_response + return @responses.delete("FETCH").collect { |i| + i[1] = Hash[*i[1]] + i + } + end + + class Atom + def format_data + return @data + end + + private + + def initialize(data) + @data = data + end + end + + class QuotedString + def format_data + return '"' + @data.gsub(/["\\]/, "\\\\\\&") + '"' + end + + private + + def initialize(data) + @data = data + end + end + + class Literal + def format_data + return "{" + @data.length.to_s + "}" + CRLF + @data + end + + private + + def initialize(data) + @data = data + end + end + + class MessageSet + def format_data + return format_internal(@data) + end + + private + + def initialize(data) + @data = data + end + + def format_internal(data) + case data + when "*" + return data + when Integer + ensure_nz_number(data) + if data == -1 + return "*" + else + return data.to_s + end + when Range + return format_internal(data.first) + + ":" + format_internal(data.last) + when Array + return data.collect {|i| format_internal(i)}.join(",") + else + raise DataFormatError, data.inspect + end + end + + def ensure_nz_number(num) + if num < -1 || num == 0 || num >= 4294967296 + raise DataFormatError, num.inspect + end + end + end + + class Response + attr_reader :prefix, :name, :data, :raw_data + + def inspect + s = @data.collect{|i| i.inspect}.join(" ") + if @name + return "#" + else + return "#" + end + end + + def method_missing(mid, *args) + return @data.send(mid, *args) + end + + private + + def initialize(prefix, data, raw_data) + @prefix = prefix + if prefix == "+" + @name = nil + else + data.each_with_index do |item, i| + if item.instance_of?(String) + @name = item + data.delete_at(i) + break + end + end + end + @data = data + @raw_data = raw_data + end + end + + class ResponseParser + def parse(str) + @str = str + @pos = 0 + @lex_state = EXPR_DATA + @token.symbol = nil + return parse_response + end + + private + + EXPR_DATA = :DATA + EXPR_TEXT = :TEXT + EXPR_CODE = :CODE + EXPR_CODE_TEXT = :CODE_TEXT + + T_NIL = :NIL + T_NUMBER = :NUMBER + T_ATOM = :ATOM + T_QUOTED = :QUOTED + T_LITERAL = :LITERAL + T_FLAG = :FLAG + T_LPAREN = :LPAREN + T_RPAREN = :RPAREN + T_STAR = :STAR + T_CRLF = :CRLF + T_EOF = :EOF + T_LBRA = :LBRA + T_RBRA = :RBRA + T_TEXT = :TEXT + + DATA_REGEXP = /\G *(?:\ +(?# 1: NIL )(NIL)|\ +(?# 2: NUMBER )(\d+)|\ +(?# 3: ATOM )([^(){ \x00-\x1f\x7f%*"\\]+)|\ +(?# 4: QUOTED )"((?:[^"\\]|\\["\\])*)"|\ +(?# 5: LITERAL )\{(\d+)\}\r\n|\ +(?# 6: FLAG )(\\(?:[^(){ \x00-\x1f\x7f%*"\\]+|\*))|\ +(?# 7: LPAREN )(\()|\ +(?# 8: RPAREN )(\))|\ +(?# 9: STAR )(\*)|\ +(?# 10: CRLF )(\r\n)|\ +(?# 11: EOF )(\z))/i + + CODE_REGEXP = /\G *(?:\ +(?# 1: NUMBER )(\d+)|\ +(?# 2: ATOM )([^(){ \x00-\x1f\x7f%*"\\\[\]]+)|\ +(?# 3: FLAG )(\\(?:[^(){ \x00-\x1f\x7f%*"\\]+|\*))|\ +(?# 4: LPAREN )(\()|\ +(?# 5: RPAREN )(\))|\ +(?# 6: LBRA )(\[)|\ +(?# 7: RBRA )(\]))/i + + CODE_TEXT_REGEXP = /\G *(?:\ +(?# 1: TEXT )([^\r\n\]]*))/i + + TEXT_REGEXP = /\G *(?:\ +(?# 1: LBRA )(\[)|\ +(?# 2: TEXT )([^\r\n]*))/i + + Token = Struct.new("Token", :symbol, :value) + + def initialize + @token = Token.new(nil, nil) + end + + def parse_response + prefix = parse_prefix + case prefix + when "+" + data = parse_resp_text + when "*" + data = parse_response_data + else + data = parse_response_cond + end + match_token(T_CRLF) + match_token(T_EOF) + return Response.new(prefix, data, @str) + end + + def parse_prefix + token = match_token(T_STAR, T_ATOM) + return token.value + end + + def parse_resp_text + val = [] + @lex_state = EXPR_TEXT + token = get_token + if token.symbol == T_LBRA + val.push(parse_resp_text_code) + end + val.push(parse_text) + @lex_state = EXPR_DATA + return val + end + + def parse_resp_text_code + val = [] + @lex_state = EXPR_CODE + match_token(T_LBRA) + token = match_token(T_ATOM) + val.push(token.value) + case token.value + when /\A(ALERT|PARSE|READ-ONLY|READ-WRITE|TRYCREATE)\z/ + # do nothing + when /\A(PERMANENTFLAGS)\z/ + token = get_token + if token.symbol != T_LPAREN + parse_error('unexpected token %s (expected "(")', + token.symbol.id2name) + end + val.push(parse_parenthesized_list) + when /\A(UIDVALIDITY|UIDNEXT|UNSEEN)\z/ + token = match_token(T_NUMBER) + val.push(token.value) + else + @lex_state = EXPR_CODE_TEXT + val.push(parse_text) + @lex_state = EXPR_CODE + end + match_token(T_RBRA) + @lex_state = EXPR_TEXT + return val + end + + def parse_text + token = match_token(T_TEXT) + return token.value + end + + def parse_response_data + token = get_token + if token.symbol == T_ATOM && + /\A(OK|NO|BAD|PREAUTH|BYE)\z/ =~ token.value + return parse_response_cond + else + return parse_data_list + end + end + + def parse_response_cond + val = [] + token = match_token(T_ATOM) + val.push(token.value) + val += parse_resp_text + return val + end + + def parse_data_list + val = [] + while true + token = get_token + case token.symbol + when T_EOF + parse_error('unexpected token %s', token.symbol.id2name) + when T_CRLF, T_RPAREN + return val + when T_LPAREN + val.push(parse_parenthesized_list) + else + val.push(token.value) + @token.symbol = nil + end + end + end + + def parse_parenthesized_list + match_token(T_LPAREN) + val = parse_data_list + match_token(T_RPAREN) + return val + end + + def match_token(*args) + token = get_token + unless args.include?(token.symbol) + parse_error('unexpected token %s (expected %s)', + token.symbol.id2name, + args.collect {|i| i.id2name}.join(" or ")) + end + @token.symbol = nil + return token + end + + def get_token + unless @token.symbol + next_token + end + return @token + end + + def next_token + case @lex_state + when EXPR_DATA + if @str.index(DATA_REGEXP, @pos) + @pos = $~.end(0) + if $1 + @token.value = nil + @token.symbol = T_NIL + elsif $2 + @token.value = $+.to_i + @token.symbol = T_NUMBER + elsif $3 + @token.value = $+.upcase + @token.symbol = T_ATOM + elsif $4 + @token.value = $+.gsub(/\\(["\\])/, "\\1") + @token.symbol = T_QUOTED + elsif $5 + len = $+.to_i + @token.value = @str[@pos, len] + @pos += len + @token.symbol = T_LITERAL + elsif $6 + @token.value = $+[1..-1].capitalize.intern + @token.symbol = T_FLAG + elsif $7 + @token.value = nil + @token.symbol = T_LPAREN + elsif $8 + @token.value = nil + @token.symbol = T_RPAREN + elsif $9 + @token.value = $+ + @token.symbol = T_STAR + elsif $10 + @token.value = nil + @token.symbol = T_CRLF + elsif $11 + @token.value = nil + @token.symbol = T_EOF + else + parse_error("[BUG] DATA_REGEXP is invalid") + end + return + end + when EXPR_TEXT + if @str.index(TEXT_REGEXP, @pos) + @pos = $~.end(0) + if $1 + @token.value = nil + @token.symbol = T_LBRA + elsif $2 + @token.value = $+ + @token.symbol = T_TEXT + else + parse_error("[BUG] TEXT_REGEXP is invalid") + end + return + end + when EXPR_CODE + if @str.index(CODE_REGEXP, @pos) + @pos = $~.end(0) + if $1 + @token.value = $+.to_i + @token.symbol = T_NUMBER + elsif $2 + @token.value = $+.upcase + @token.symbol = T_ATOM + elsif $3 + @token.value = $+[1..-1].capitalize.intern + @token.symbol = T_FLAG + elsif $4 + @token.value = nil + @token.symbol = T_LPAREN + elsif $5 + @token.value = nil + @token.symbol = T_RPAREN + elsif $6 + @token.value = nil + @token.symbol = T_LBRA + elsif $7 + @token.value = nil + @token.symbol = T_RBRA + else + parse_error("[BUG] CODE_REGEXP is invalid") + end + return + end + when EXPR_CODE_TEXT + if @str.index(CODE_TEXT_REGEXP, @pos) + @pos = $~.end(0) + if $1 + @token.value = $+ + @token.symbol = T_TEXT + else + parse_error("[BUG] CODE_TEXT_REGEXP is invalid") + end + return + end + else + parse_error("illegal @lex_state - %s", @lex_state.inspect) + end + @str.index(/\S*/, @pos) + parse_error("unknown token - %s", $&.dump) + end + + def parse_error(fmt, *args) + if @@debug + $stderr.printf("@str: %s\n", @str.dump) + $stderr.printf("@pos: %d\n", @pos) + if @token.symbol + $stderr.printf("@token.symbol: %s\n", @token.symbol.id2name) + $stderr.printf("@token.value: %s\n", @token.value.inspect) + end + end + raise ResponseParseError, format(fmt, *args) + end + end + + class LoginAuthenticator + def process(data) + case @state + when STATE_USER + @state = STATE_PASSWORD + return @user + when STATE_PASSWORD + return @password + end + end + + private + + STATE_USER = :USER + STATE_PASSWORD = :PASSWORD + + def initialize(user, password) + @user = user + @password = password + @state = STATE_USER + end + end + + class CramMD5Authenticator + def process(challenge) + digest = hmac_md5(challenge, @password) + return @user + " " + digest + end + + private + + def initialize(user, password) + @user = user + @password = password + end + + def hmac_md5(text, key) + if key.length > 64 + md5 = MD5.new(key) + key = md5.digest + end + + k_ipad = key + "\0" * (64 - key.length) + k_opad = key + "\0" * (64 - key.length) + for i in 0..63 + k_ipad[i] ^= 0x36 + k_opad[i] ^= 0x5c + end + + md5 = MD5.new + md5.update(k_ipad) + md5.update(text) + digest = md5.digest + + md5 = MD5.new + md5.update(k_opad) + md5.update(digest) + return md5.hexdigest + end + end + + AUTHENTICATORS = { + "LOGIN" => LoginAuthenticator, + "CRAM-MD5" => CramMD5Authenticator + } + + class Error < StandardError + end + + class DataFormatError < Error + end + + class ResponseParseError < Error + end + + class ResponseError < Error + end + + class NoResponseError < ResponseError + end + + class BadResponseError < ResponseError + end + + class ByeResponseError < ResponseError + end + end +end -- cgit v1.2.3