imap_processor 1.3 → 1.5
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
- 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
|