imap_processor 1.3 → 1.5
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- checksums.yaml.gz.sig +0 -0
- data.tar.gz.sig +0 -0
- data/.gemtest +0 -0
- data/History.rdoc +83 -0
- data/Manifest.txt +11 -2
- data/{README.txt → README.rdoc} +13 -6
- data/Rakefile +6 -3
- data/bin/imap_cleanse +5 -0
- data/bin/imap_flag +5 -0
- data/bin/imap_learn +5 -0
- data/bin/imap_mkdir +5 -0
- data/lib/imap_processor.rb +206 -150
- data/lib/imap_processor/archive.rb +58 -13
- data/lib/imap_processor/cleanse.rb +67 -0
- data/lib/imap_processor/client.rb +145 -0
- data/lib/imap_processor/flag.rb +121 -0
- data/lib/imap_processor/learn.rb +231 -0
- data/lib/imap_processor/mkdir.rb +25 -0
- data/test/test_imap_processor.rb +37 -0
- metadata +121 -72
- metadata.gz.sig +0 -0
- data/History.txt +0 -43
@@ -1,15 +1,17 @@
|
|
1
1
|
require 'imap_processor'
|
2
|
+
require "time"
|
2
3
|
|
3
4
|
##
|
4
5
|
# Archives old mail on IMAP server by moving it to dated mailboxen.
|
5
6
|
|
6
7
|
class IMAPProcessor::Archive < IMAPProcessor
|
7
|
-
attr_reader :list, :move
|
8
|
+
attr_reader :list, :move, :sep, :split
|
8
9
|
|
9
10
|
def self.process_args(args)
|
10
11
|
required_options = {
|
11
12
|
:List => true,
|
12
13
|
:Move => false,
|
14
|
+
:Split => false,
|
13
15
|
}
|
14
16
|
|
15
17
|
super __FILE__, args, required_options do |opts, options|
|
@@ -24,6 +26,17 @@ imap_archive archives old mail on IMAP server by moving it to dated mailboxen.
|
|
24
26
|
opts.on("--[no-]move", "Move the messages (off by default)") do |move|
|
25
27
|
options[:Move] = move
|
26
28
|
end
|
29
|
+
|
30
|
+
opts.on("--[no-]split", "Split mailbox into multiple months (off by default)") do |move|
|
31
|
+
options[:Split] = move
|
32
|
+
end
|
33
|
+
|
34
|
+
opts.on("-s", "--sep SEPARATOR",
|
35
|
+
"Mailbox date separator character",
|
36
|
+
"Default: Read from ~/.#{@@opts_file_name}",
|
37
|
+
"Options file name: :Sep") do |sep|
|
38
|
+
options[:Sep] = sep
|
39
|
+
end
|
27
40
|
end
|
28
41
|
end
|
29
42
|
|
@@ -32,6 +45,8 @@ imap_archive archives old mail on IMAP server by moving it to dated mailboxen.
|
|
32
45
|
|
33
46
|
@list = options[:List]
|
34
47
|
@move = options[:Move]
|
48
|
+
@sep = options[:Sep] || '.'
|
49
|
+
@split = options[:Split]
|
35
50
|
|
36
51
|
connection = connect
|
37
52
|
|
@@ -40,7 +55,7 @@ imap_archive archives old mail on IMAP server by moving it to dated mailboxen.
|
|
40
55
|
|
41
56
|
def the_first
|
42
57
|
t = Time.now
|
43
|
-
|
58
|
+
Time.local(t.year, t.month, 1)
|
44
59
|
end
|
45
60
|
|
46
61
|
def last_month
|
@@ -57,25 +72,55 @@ imap_archive archives old mail on IMAP server by moving it to dated mailboxen.
|
|
57
72
|
|
58
73
|
def run
|
59
74
|
@boxes.each do |mailbox|
|
60
|
-
destination = "#{mailbox}.#{last_month}"
|
61
|
-
|
62
75
|
log "SELECT #{mailbox}"
|
63
|
-
|
76
|
+
imap.select mailbox
|
64
77
|
|
65
|
-
|
78
|
+
uids_by_date = self.uids_by_date
|
66
79
|
|
67
|
-
|
68
|
-
uids = imap.search search
|
80
|
+
next if uids_by_date.empty?
|
69
81
|
|
70
|
-
|
82
|
+
unless split then
|
83
|
+
today = Time.now
|
84
|
+
d = today - 86400 * today.day
|
85
|
+
latest = [d.year, d.month]
|
71
86
|
|
72
|
-
|
87
|
+
uids_by_date = {
|
88
|
+
latest => uids_by_date.values.flatten(1)
|
89
|
+
}
|
90
|
+
end
|
73
91
|
|
74
|
-
|
92
|
+
uids_by_date.sort.each do |date, uids|
|
93
|
+
next if uids.empty?
|
94
|
+
destination = "#{mailbox}#{sep}%4d-%02d" % date
|
95
|
+
puts "#{destination}:"
|
96
|
+
puts
|
97
|
+
show_messages uids
|
98
|
+
move_messages uids, destination, false if move
|
99
|
+
end
|
75
100
|
|
76
|
-
|
101
|
+
log "EXPUNGE"
|
102
|
+
imap.expunge
|
77
103
|
end
|
78
104
|
end
|
79
105
|
|
80
|
-
|
106
|
+
def uids_by_date
|
107
|
+
search = make_search
|
108
|
+
log "SEARCH #{search.join ' '}"
|
109
|
+
uids = imap.search search
|
110
|
+
|
111
|
+
return {} if uids.empty?
|
112
|
+
|
113
|
+
payload = imap.fetch(uids, 'BODY.PEEK[HEADER.FIELDS (DATE)]')
|
114
|
+
|
115
|
+
mail = Hash[uids.zip(payload).map { |uid, m|
|
116
|
+
date = m.attr["BODY[HEADER.FIELDS (DATE)]"].strip.split(/:\s*/, 2).last
|
117
|
+
date = Time.parse(date) rescue Time.now
|
118
|
+
[uid, date]
|
119
|
+
}]
|
81
120
|
|
121
|
+
mail.keys.group_by { |uid|
|
122
|
+
date = mail[uid]
|
123
|
+
[date.year, date.month]
|
124
|
+
}
|
125
|
+
end
|
126
|
+
end
|
@@ -0,0 +1,67 @@
|
|
1
|
+
require 'imap_processor/client'
|
2
|
+
|
3
|
+
##
|
4
|
+
# Cleanse removes old messages from your IMAP mailboxes so you don't
|
5
|
+
# have to!
|
6
|
+
#
|
7
|
+
# aka part one of my Plan for Total Email Domination.
|
8
|
+
#
|
9
|
+
# Cleanse doesn't remove messages you haven't read nor messages you've
|
10
|
+
# flagged.
|
11
|
+
|
12
|
+
class IMAPProcessor::Cleanse < IMAPProcessor::Client
|
13
|
+
|
14
|
+
##
|
15
|
+
# Creates a new Cleanse from +options+.
|
16
|
+
#
|
17
|
+
# Options include:
|
18
|
+
# +:Age+:: Delete messages older than this many days ago
|
19
|
+
#
|
20
|
+
# and all options from IMAPClient
|
21
|
+
|
22
|
+
def initialize(options)
|
23
|
+
super
|
24
|
+
|
25
|
+
@cleanse = options[:cleanse]
|
26
|
+
@boxes = @cleanse.keys
|
27
|
+
end
|
28
|
+
|
29
|
+
def self.process_args(args)
|
30
|
+
super __FILE__, args, {} do |opts, options|
|
31
|
+
opts.banner << <<-EOF
|
32
|
+
imap_cleanse removes old messages from your IMAP mailboxes.
|
33
|
+
EOF
|
34
|
+
end
|
35
|
+
end
|
36
|
+
|
37
|
+
##
|
38
|
+
# Removes read, unflagged messages from all selected mailboxes...
|
39
|
+
|
40
|
+
def run
|
41
|
+
super "Cleansing read, unflagged old messages",
|
42
|
+
[:Deleted] do
|
43
|
+
@imap.expunge
|
44
|
+
log "Expunged deleted messages"
|
45
|
+
end
|
46
|
+
end
|
47
|
+
|
48
|
+
private
|
49
|
+
|
50
|
+
##
|
51
|
+
# Searches for read, unflagged messages older than :Age in the currently
|
52
|
+
# selected mailbox (see Net::IMAP#select).
|
53
|
+
|
54
|
+
def find_messages
|
55
|
+
mailbox = @boxes.find { |box| @mailbox =~ /#{box}/ } # TODO: needs more work
|
56
|
+
raise unless mailbox
|
57
|
+
age = @cleanse[mailbox]
|
58
|
+
before_date = (Time.now - 86400 * age).imapdate
|
59
|
+
|
60
|
+
search [
|
61
|
+
'NOT', 'NEW',
|
62
|
+
'NOT', 'FLAGGED',
|
63
|
+
'BEFORE', before_date
|
64
|
+
], 'read, unflagged messages'
|
65
|
+
end
|
66
|
+
|
67
|
+
end
|
@@ -0,0 +1,145 @@
|
|
1
|
+
$TESTING = false unless defined? $TESTING
|
2
|
+
# require 'net/imap'
|
3
|
+
# require 'yaml'
|
4
|
+
# require 'imap_sasl_plain'
|
5
|
+
# require 'optparse'
|
6
|
+
# require 'enumerator'
|
7
|
+
|
8
|
+
require "imap_processor"
|
9
|
+
|
10
|
+
##
|
11
|
+
# This class only exists to transition from IMAPCleanse to imap_processor
|
12
|
+
|
13
|
+
class IMAPProcessor::Client < IMAPProcessor
|
14
|
+
|
15
|
+
##
|
16
|
+
# Creates a new IMAPClient from +options+.
|
17
|
+
#
|
18
|
+
# Options include:
|
19
|
+
# +:Verbose+:: Verbose flag
|
20
|
+
# +:Noop+:: Don't delete anything flag
|
21
|
+
# +:Root+:: IMAP root path
|
22
|
+
# +:Boxes+:: Comma-separated list of mailbox prefixes to search
|
23
|
+
# +:Host+:: IMAP server
|
24
|
+
# +:Port+:: IMAP server port
|
25
|
+
# +:SSL+:: SSL flag
|
26
|
+
# +:Username+:: IMAP username
|
27
|
+
# +:Password+:: IMAP password
|
28
|
+
# +:Auth+:: IMAP authentication type
|
29
|
+
|
30
|
+
def initialize(options)
|
31
|
+
super
|
32
|
+
|
33
|
+
@noop = options[:Noop]
|
34
|
+
@root = options[:Root]
|
35
|
+
|
36
|
+
root = @root
|
37
|
+
root += "/" unless root.empty?
|
38
|
+
|
39
|
+
connect options[:Host], options[:Port], options[:SSL],
|
40
|
+
options[:Username], options[:Password], options[:Auth]
|
41
|
+
end
|
42
|
+
|
43
|
+
##
|
44
|
+
# Selects messages from mailboxes then marking them with +flags+. If a
|
45
|
+
# block is given it is run after message marking.
|
46
|
+
#
|
47
|
+
# Unless :Noop was set, then it just prints out what it would do.
|
48
|
+
#
|
49
|
+
# Automatically called by IMAPClient::run
|
50
|
+
|
51
|
+
def run(message, flags)
|
52
|
+
log message
|
53
|
+
|
54
|
+
message_count = 0
|
55
|
+
mailboxes = find_mailboxes
|
56
|
+
|
57
|
+
mailboxes.each do |mailbox|
|
58
|
+
@mailbox = mailbox
|
59
|
+
@imap.select @mailbox
|
60
|
+
log "Selected #{@mailbox}"
|
61
|
+
|
62
|
+
messages = find_messages
|
63
|
+
|
64
|
+
next if messages.empty?
|
65
|
+
|
66
|
+
message_count += messages.length
|
67
|
+
|
68
|
+
unless @noop then
|
69
|
+
mark messages, flags
|
70
|
+
else
|
71
|
+
log "Noop - not marking"
|
72
|
+
end
|
73
|
+
|
74
|
+
yield messages if block_given?
|
75
|
+
end
|
76
|
+
|
77
|
+
log "Done. Found #{message_count} messages in #{mailboxes.length} mailboxes"
|
78
|
+
end
|
79
|
+
|
80
|
+
##
|
81
|
+
# Connects to IMAP server +host+ at +port+ using ssl if +ssl+ is true then
|
82
|
+
# logs in as +username+ with +password+. IMAPClient will really only work
|
83
|
+
# with PLAIN auth on SSL sockets, sorry.
|
84
|
+
|
85
|
+
def connect(host, port, ssl, username, password, auth = nil)
|
86
|
+
@imap = Net::IMAP.new host, port, ssl, nil, false
|
87
|
+
log "Connected to #{host}:#{port}"
|
88
|
+
|
89
|
+
if auth.nil? then
|
90
|
+
auth_caps = @imap.capability.select { |c| c =~ /^AUTH/ }
|
91
|
+
raise "Couldn't find a supported auth type" if auth_caps.empty?
|
92
|
+
auth = auth_caps.first.sub(/AUTH=/, '')
|
93
|
+
end
|
94
|
+
|
95
|
+
auth = auth.upcase
|
96
|
+
log "Trying #{auth} authentication"
|
97
|
+
@imap.authenticate auth, username, password
|
98
|
+
log "Logged in as #{username}"
|
99
|
+
end
|
100
|
+
|
101
|
+
##
|
102
|
+
# Finds mailboxes with messages that were selected by the :Boxes option.
|
103
|
+
|
104
|
+
def find_mailboxes
|
105
|
+
mailboxes = @imap.list(@root, "*")
|
106
|
+
|
107
|
+
if mailboxes.nil? then
|
108
|
+
log "Found no mailboxes under #{@root.inspect}, you may have an incorrect root"
|
109
|
+
return []
|
110
|
+
end
|
111
|
+
|
112
|
+
mailboxes.reject! { |mailbox| mailbox.attr.include? :Noselect }
|
113
|
+
mailboxes.map! { |mailbox| mailbox.name }
|
114
|
+
|
115
|
+
@box_re = /^#{Regexp.escape @root}#{Regexp.union(*@boxes)}/
|
116
|
+
|
117
|
+
mailboxes.reject! { |mailbox| mailbox !~ @box_re }
|
118
|
+
mailboxes = mailboxes.sort_by { |m| m.downcase }
|
119
|
+
log "Found #{mailboxes.length} mailboxes to search:"
|
120
|
+
mailboxes.each { |mailbox| log "\t#{mailbox}" } if @verbose
|
121
|
+
return mailboxes
|
122
|
+
end
|
123
|
+
|
124
|
+
##
|
125
|
+
# Searches for messages matching +query+ in the selected mailbox
|
126
|
+
# (see Net::IMAP#select). Logs 'Scanning for +message+' before searching.
|
127
|
+
|
128
|
+
def search(query, message)
|
129
|
+
log " Scanning for #{message}"
|
130
|
+
messages = @imap.search query
|
131
|
+
log " Found #{messages.length} messages"
|
132
|
+
return messages
|
133
|
+
end
|
134
|
+
|
135
|
+
##
|
136
|
+
# Marks +messages+ in the currently selected mailbox with +flags+
|
137
|
+
# (see Net::IMAP#store).
|
138
|
+
|
139
|
+
def mark(messages, flags)
|
140
|
+
messages.each_slice(500) do |chunk|
|
141
|
+
@imap.store chunk, '+FLAGS.SILENT', flags
|
142
|
+
end
|
143
|
+
log "Marked messages with flags"
|
144
|
+
end
|
145
|
+
end
|
@@ -0,0 +1,121 @@
|
|
1
|
+
require 'imap_processor/client'
|
2
|
+
|
3
|
+
##
|
4
|
+
# Automatically flag your messages, yo!
|
5
|
+
#
|
6
|
+
# aka part two of my Plan for Total Email Domination.
|
7
|
+
#
|
8
|
+
# IMAPFlag flags messages you've responded to, messages you've written and
|
9
|
+
# messages in response to messages you've written.
|
10
|
+
#
|
11
|
+
# If you unflag a message IMAPFlag is smart and doesn't re-flag it.
|
12
|
+
#
|
13
|
+
# I chose these settings because I find these messages interesting but don't
|
14
|
+
# want to manually flag them. Why should I do all the clicking when the
|
15
|
+
# computer can do it for me?
|
16
|
+
|
17
|
+
class IMAPProcessor::Flag < IMAPProcessor::Client
|
18
|
+
|
19
|
+
##
|
20
|
+
# IMAP keyword for automatically flagged messages
|
21
|
+
|
22
|
+
AUTO_FLAG_KEYWORD = 'IMAPFLAG_AUTO_FLAGGED'
|
23
|
+
|
24
|
+
##
|
25
|
+
# Message-Id query
|
26
|
+
|
27
|
+
MESSAGE_ID = 'HEADER.FIELDS (MESSAGE-ID)'
|
28
|
+
|
29
|
+
##
|
30
|
+
# Creates a new IMAPFlag from +options+.
|
31
|
+
#
|
32
|
+
# Options include:
|
33
|
+
# +:Email:: Email address used for sending email
|
34
|
+
#
|
35
|
+
# and all options from IMAPClient
|
36
|
+
|
37
|
+
def initialize(options)
|
38
|
+
super
|
39
|
+
|
40
|
+
@flag = options[:flag]
|
41
|
+
@boxes = @flag.keys
|
42
|
+
end
|
43
|
+
|
44
|
+
def self.process_args(args)
|
45
|
+
super __FILE__, args, {} do |opts, options|
|
46
|
+
opts.banner << <<-EOF
|
47
|
+
imap_flag automatically flags your messages.
|
48
|
+
EOF
|
49
|
+
end
|
50
|
+
end
|
51
|
+
|
52
|
+
##
|
53
|
+
# Removes read, unflagged messages from all selected mailboxes...
|
54
|
+
|
55
|
+
def run
|
56
|
+
super "Flagging messages", [:Flagged, AUTO_FLAG_KEYWORD]
|
57
|
+
end
|
58
|
+
|
59
|
+
##
|
60
|
+
# Searches for messages I answered and messages I wrote.
|
61
|
+
|
62
|
+
def find_messages
|
63
|
+
@box = @boxes.find { |box| @mailbox =~ /#{box}/ } # TODO: needs more work
|
64
|
+
raise unless @box
|
65
|
+
@email = @flag[@box]
|
66
|
+
raise unless @email
|
67
|
+
return [answered_in_curr, wrote_in_curr, responses_in_curr].flatten
|
68
|
+
end
|
69
|
+
|
70
|
+
##
|
71
|
+
# Answered messages in the selected mailbox.
|
72
|
+
|
73
|
+
def answered_in_curr
|
74
|
+
search [
|
75
|
+
'ANSWERED',
|
76
|
+
'NOT', 'FLAGGED',
|
77
|
+
'NOT', 'KEYWORD', AUTO_FLAG_KEYWORD
|
78
|
+
], 'answered messages'
|
79
|
+
end
|
80
|
+
|
81
|
+
def all_email
|
82
|
+
@email.map { |e| "FROM #{e}" }.inject { |s,e| "OR #{s} #{e}" }
|
83
|
+
end
|
84
|
+
|
85
|
+
##
|
86
|
+
# Messages I wrote in the selected mailbox.
|
87
|
+
|
88
|
+
def wrote_in_curr
|
89
|
+
search("#{self.all_email} NOT FLAGGED NOT KEYWORD AUTO_FLAG_KEYWORD",
|
90
|
+
"messages by #{@email.join(", ")}")
|
91
|
+
end
|
92
|
+
|
93
|
+
##
|
94
|
+
# Messages in response to messages I wrote in the selected mailbox.
|
95
|
+
|
96
|
+
def responses_in_curr
|
97
|
+
log " Scanning for responses to messages I wrote"
|
98
|
+
my_mail = @imap.search self.all_email
|
99
|
+
|
100
|
+
return [] if my_mail.empty?
|
101
|
+
|
102
|
+
msg_ids = @imap.fetch my_mail, "BODY.PEEK[#{MESSAGE_ID}]"
|
103
|
+
msg_ids.map! do |data|
|
104
|
+
data.attr["BODY[#{MESSAGE_ID}]"].split(':', 2).last.strip
|
105
|
+
end
|
106
|
+
|
107
|
+
messages = msg_ids.map do |id|
|
108
|
+
@imap.search([
|
109
|
+
'HEADER', 'In-Reply-To', id,
|
110
|
+
'NOT', 'FLAGGED',
|
111
|
+
'NOT', 'KEYWORD', AUTO_FLAG_KEYWORD
|
112
|
+
])
|
113
|
+
end
|
114
|
+
|
115
|
+
messages.flatten!
|
116
|
+
|
117
|
+
log " Found #{messages.length} messages"
|
118
|
+
|
119
|
+
return messages
|
120
|
+
end
|
121
|
+
end
|