8 # $Id$ |
8 # $Id$ |
9 # |
9 # |
10 #--- |
10 #--- |
11 |
11 |
12 require 'pathname' |
12 require 'pathname' |
|
13 require 'etc' |
13 require 'ezmlm' |
14 require 'ezmlm' |
14 require 'mail' |
15 require 'mail' |
15 |
16 |
16 |
17 |
17 ### A Ruby interface to an ezmlm-idx mailing list directory |
18 ### A Ruby interface to an ezmlm-idx mailing list directory |
18 ### |
19 ### |
19 class Ezmlm::List |
20 class Ezmlm::List |
20 |
21 |
|
22 # Quick address space detection, to (hopefully) |
|
23 # match the overflow size on this machine. |
|
24 # |
|
25 ADDRESS_SPACE = case [ 'i' ].pack( 'p' ).size |
|
26 when 4 |
|
27 32 |
|
28 when 8 |
|
29 64 |
|
30 end |
|
31 |
|
32 # Valid subdirectories/sections for subscriptions. |
|
33 SUBSCRIPTION_DIRS = %w[ deny mod digest allow ] |
|
34 |
|
35 |
21 ### Create a new Ezmlm::List object for the specified +listdir+, which should be |
36 ### Create a new Ezmlm::List object for the specified +listdir+, which should be |
22 ### an ezmlm-idx mailing list directory. |
37 ### an ezmlm-idx mailing list directory. |
23 ### |
38 ### |
24 def initialize( listdir ) |
39 def initialize( listdir ) |
25 listdir = Pathname.new( listdir ) unless listdir.is_a?( Pathname ) |
40 listdir = Pathname.new( listdir ) unless listdir.is_a?( Pathname ) |
26 @listdir = listdir |
41 @listdir = listdir |
27 end |
42 end |
28 |
43 |
29 |
|
30 ###### |
|
31 public |
|
32 ###### |
|
33 |
|
34 # The Pathname object for the list directory |
44 # The Pathname object for the list directory |
35 attr_reader :listdir |
45 attr_reader :listdir |
36 |
46 |
37 |
47 |
38 ### Return the configured name of the list (without the host) |
48 ### Return the configured name of the list (without the host) |
|
49 ### |
39 def name |
50 def name |
40 return self.config[ 'L' ] |
51 @name = self.read( 'outlocal' ) unless @name |
|
52 return @name |
41 end |
53 end |
42 |
54 |
43 |
55 |
44 ### Return the configured host of the list |
56 ### Return the configured host of the list |
|
57 ### |
45 def host |
58 def host |
46 return self.config[ 'H' ] |
59 @host = self.read( 'outhost' ) unless @host |
|
60 return @host |
47 end |
61 end |
48 |
62 |
49 |
63 |
50 ### Return the configured address of the list (in list@host form) |
64 ### Return the configured address of the list (in list@host form) |
|
65 ### |
51 def address |
66 def address |
52 return "%s@%s" % [ self.name, self.host ] |
67 return "%s@%s" % [ self.name, self.host ] |
53 end |
68 end |
54 alias_method :fullname, :address |
69 alias_method :fullname, :address |
55 |
70 |
56 |
71 |
57 ### Return the number of messages in the list archive |
72 ### Return the email address of the list's owner. |
|
73 ### |
|
74 def owner |
|
75 owner = self.read( 'owner' ) |
|
76 return owner =~ /@/ ? owner : nil |
|
77 end |
|
78 |
|
79 |
|
80 ### Return the number of messages in the list archive. |
|
81 ### |
58 def message_count |
82 def message_count |
59 numfile = self.listdir + 'num' |
83 count = self.read( 'archnum' ) |
60 return 0 unless numfile.exist? |
84 return count ? Integer( count ) : 0 |
61 return Integer( numfile.read[/^(\d+):/, 1] ) |
85 end |
62 end |
86 |
63 |
87 |
64 |
88 ### Fetch a sorted Array of the email addresses for all of the list's |
|
89 ### subscribers. |
|
90 ### |
|
91 def subscribers |
|
92 return self.read_subscriber_dir |
|
93 end |
|
94 |
|
95 |
|
96 ### Returns an Array of email addresses of people responsible for |
|
97 ### moderating subscription of a closed list. |
|
98 ### |
|
99 def moderators |
|
100 return self.read_subscriber_dir( 'mod' ) |
|
101 end |
|
102 |
|
103 |
|
104 ### Subscribe +addr+ to the list as a Moderator. |
|
105 ### |
|
106 def add_moderator( *addr ) |
|
107 return self.subscribe( *addr, section: 'mod' ) |
|
108 end |
|
109 |
|
110 |
|
111 ### Remove +addr+ from the list as a Moderator. |
|
112 ### |
|
113 def remove_moderator( *addr ) |
|
114 return self.unsubscribe( *addr, section: 'mod' ) |
|
115 end |
|
116 |
|
117 |
|
118 ### Returns +true+ if +address+ is a subscriber to this list. |
|
119 ### |
|
120 def include?( addr ) |
|
121 addr.downcase! |
|
122 file = self.subscription_dir + self.hashchar( addr ) |
|
123 return false unless file.exist? |
|
124 return file.read.scan( /T([^\0]+)\0/ ).flatten.include?( addr ) |
|
125 end |
|
126 |
|
127 |
|
128 ### Subscribe +addr+ to the list within +section+. |
|
129 ### |
|
130 def subscribe( *addr, section: nil ) |
|
131 addr.each do |address| |
|
132 next unless address.index( '@' ) |
|
133 address.downcase! |
|
134 |
|
135 file = self.subscription_dir( section ) + self.hashchar( address ) |
|
136 self.with_safety do |
|
137 if file.exist? |
|
138 addresses = file.read.scan( /T([^\0]+)\0/ ).flatten |
|
139 addresses << address |
|
140 file.open( 'w' ) do |f| |
|
141 f.print addresses.uniq.sort.map{|a| "T#{a}\0" }.join |
|
142 end |
|
143 |
|
144 else |
|
145 file.open( 'w' ) do |f| |
|
146 f.print "T%s\0" % [ address ] |
|
147 end |
|
148 end |
|
149 end |
|
150 end |
|
151 end |
|
152 |
|
153 |
|
154 ### Unsubscribe +addr+ from the list within +section+. |
|
155 ### |
|
156 def unsubscribe( *addr, section: nil ) |
|
157 addr.each do |address| |
|
158 address.downcase! |
|
159 |
|
160 file = self.subscribers_dir( section ) + self.hashchar( address ) |
|
161 self.with_safety do |
|
162 next unless file.exist? |
|
163 addresses = file.read.scan( /T([^\0]+)\0/ ).flatten |
|
164 addresses = addresses - [ address ] |
|
165 |
|
166 if addresses.empty? |
|
167 file.unlink |
|
168 else |
|
169 file.open( 'w' ) do |f| |
|
170 f.print addresses.uniq.sort.map{|a| "T#{a}\0" }.join |
|
171 end |
|
172 end |
|
173 end |
|
174 end |
|
175 end |
|
176 |
|
177 |
|
178 =begin |
65 ### Return the Date parsed from the last post to the list. |
179 ### Return the Date parsed from the last post to the list. |
|
180 ### |
66 def last_message_date |
181 def last_message_date |
67 mail = self.last_post or return nil |
182 mail = self.last_post or return nil |
68 return mail.date |
183 return mail.date |
69 end |
184 end |
70 |
185 |
71 |
186 |
72 ### Return the author of the last post to the list. |
187 ### Return the author of the last post to the list. |
|
188 ### |
73 def last_message_author |
189 def last_message_author |
74 mail = self.last_post or return nil |
190 mail = self.last_post or return nil |
75 return mail.from |
191 return mail.from |
76 end |
192 end |
77 |
193 |
78 |
194 |
79 ### Return the list config as a Hash |
|
80 def config |
|
81 unless @config |
|
82 configfile = self.listdir + 'config' |
|
83 raise "List config file %p does not exist" % [ configfile ] unless configfile.exist? |
|
84 |
|
85 @config = configfile.read.scan( /^(\S):([^\n]*)$/m ).inject({}) do |h,pair| |
|
86 key,val = *pair |
|
87 h[key] = val |
|
88 h |
|
89 end |
|
90 end |
|
91 |
|
92 return @config |
|
93 end |
|
94 |
|
95 |
|
96 ### Return the email address of the list's owner. |
|
97 def owner |
|
98 self.config['5'] |
|
99 end |
|
100 |
|
101 |
|
102 ### Fetch an Array of the email addresses for all of the list's subscribers. |
|
103 def subscribers |
|
104 subscribers_dir = self.listdir + 'subscribers' |
|
105 return self.read_subscriber_dir( subscribers_dir ) |
|
106 end |
|
107 |
|
108 |
|
109 ### Returns +true+ if subscription to the list is moderated. |
195 ### Returns +true+ if subscription to the list is moderated. |
|
196 ### |
110 def closed? |
197 def closed? |
111 return (self.listdir + 'modsub').exist? || (self.listdir + 'remote').exist? |
198 return (self.listdir + 'modsub').exist? || (self.listdir + 'remote').exist? |
112 end |
199 end |
113 |
200 |
114 |
201 |
115 ### Returns +true+ if posting to the list is moderated. |
202 ### Returns +true+ if posting to the list is moderated. |
|
203 ### |
116 def moderated? |
204 def moderated? |
117 return (self.listdir + 'modpost').exist? |
205 return (self.listdir + 'modpost').exist? |
118 end |
206 end |
119 |
207 |
120 |
208 |
121 ### Returns an Array of email addresses of people responsible for moderating subscription |
209 ### Return a Mail::Message object loaded from the last post to the list. Returns |
122 ### of a closed list. |
|
123 def subscription_moderators |
|
124 return [] unless self.closed? |
|
125 |
|
126 modsubfile = self.listdir + 'modsub' |
|
127 remotefile = self.listdir + 'remote' |
|
128 |
|
129 subdir = nil |
|
130 if modsubfile.exist? && modsubfile.read(1) == '/' |
|
131 subdir = Pathname.new( modsubfile.read.chomp ) |
|
132 elsif remotefile.exist? && remotefile.read(1) == '/' |
|
133 subdir = Pathname.new( remotefile.read.chomp ) |
|
134 else |
|
135 subdir = self.listdir + 'mod/subscribers' |
|
136 end |
|
137 |
|
138 return self.read_subscriber_dir( subdir ) |
|
139 end |
|
140 |
|
141 |
|
142 ### Returns an Array of email addresses of people responsible for moderating posts |
|
143 ### sent to the list. |
|
144 def message_moderators |
|
145 return [] unless self.moderated? |
|
146 |
|
147 modpostfile = self.listdir + 'modpost' |
|
148 subdir = nil |
|
149 |
|
150 if modpostfile.exist? && modpostfile.read(1) == '/' |
|
151 subdir = Pathname.new( modpostfile.read.chomp ) |
|
152 else |
|
153 subdir = self.listdir + 'mod/subscribers' |
|
154 end |
|
155 |
|
156 return self.read_subscriber_dir( subdir ) |
|
157 end |
|
158 |
|
159 |
|
160 ### Return a TMail::Mail object loaded from the last post to the list. Returns |
|
161 ### +nil+ if there are no archived posts. |
210 ### +nil+ if there are no archived posts. |
|
211 ### |
162 def last_post |
212 def last_post |
163 archivedir = self.listdir + 'archive' |
213 archivedir = self.listdir + 'archive' |
164 return nil unless archivedir.exist? |
214 return nil unless archivedir.exist? |
165 |
215 |
166 # Find the last numbered directory under the archive dir |
216 # Find the last numbered directory under the archive dir |
175 sort_by {|pn| pn.basename.to_s }.last |
225 sort_by {|pn| pn.basename.to_s }.last |
176 |
226 |
177 raise RuntimeError, "unexpectedly empty archive directory '%s'" % [ last_archdir ] \ |
227 raise RuntimeError, "unexpectedly empty archive directory '%s'" % [ last_archdir ] \ |
178 unless last_post_path |
228 unless last_post_path |
179 |
229 |
|
230 require 'pry' |
|
231 binding.pry |
180 last_post = TMail::Mail.load( last_post_path.to_s ) |
232 last_post = TMail::Mail.load( last_post_path.to_s ) |
181 end |
233 end |
182 |
234 =end |
183 |
235 |
184 |
236 |
185 ######### |
237 ######### |
186 protected |
238 protected |
187 ######### |
239 ######### |
188 |
240 |
|
241 ### Hash an email address, using the ezmlm algorithm for |
|
242 ### fast user lookups. Returns the hashed integer. |
|
243 ### |
|
244 ### Older ezmlm didn't lowercase addresses, anything within the last |
|
245 ### decade did. We're not going to worry about compatibility there. |
|
246 ### |
|
247 ### (See subhash.c in the ezmlm source.) |
|
248 ### |
|
249 def subhash( addr ) |
|
250 h = 5381 |
|
251 over = 2 ** ADDRESS_SPACE |
|
252 |
|
253 addr = 'T' + addr |
|
254 addr.each_char do |c| |
|
255 h = ( h + ( h << 5 ) ) ^ c.ord |
|
256 h = h % over if h > over # emulate integer overflow |
|
257 end |
|
258 return h % 53 |
|
259 end |
|
260 |
|
261 |
|
262 ### Given an email address, return the ascii character. |
|
263 ### |
|
264 def hashchar( addr ) |
|
265 return ( self.subhash(addr) + 64 ).chr |
|
266 end |
|
267 |
|
268 |
|
269 ### Just return the contents of the provided +file+, rooted |
|
270 ### in the list directory. |
|
271 ### |
|
272 def read( file ) |
|
273 file = self.listdir + file unless file.is_a?( Pathname ) |
|
274 return file.read.chomp |
|
275 rescue |
|
276 nil |
|
277 end |
|
278 |
|
279 |
|
280 ### Return a Pathname to a subscription directory. |
|
281 ### |
|
282 def subscription_dir( section=nil ) |
|
283 section = nil if section && ! SUBSCRIPTION_DIRS.include?( section ) |
|
284 |
|
285 if section |
|
286 return self.listdir + section + 'subscribers' |
|
287 else |
|
288 return self.listdir + 'subscribers' |
|
289 end |
|
290 end |
|
291 |
|
292 |
189 ### Read the hashed subscriber email addresses from the specified +directory+ and return them in |
293 ### Read the hashed subscriber email addresses from the specified +directory+ and return them in |
190 ### an Array. |
294 ### an Array. |
191 def read_subscriber_dir( directory ) |
295 ### |
|
296 def read_subscriber_dir( section=nil ) |
|
297 directory = self.subscription_dir( section ) |
192 rval = [] |
298 rval = [] |
193 Pathname.glob( directory + '*' ) do |hashfile| |
299 Pathname.glob( directory + '*' ) do |hashfile| |
194 rval.push( hashfile.read.scan(/T([^\0]+)\0/) ) |
300 rval.push( hashfile.read.scan(/T([^\0]+)\0/) ) |
195 end |
301 end |
196 |
302 |
197 return rval.flatten |
303 return rval.flatten.sort |
|
304 end |
|
305 |
|
306 |
|
307 ### Return a Pathname object for the list owner's home directory. |
|
308 ### |
|
309 def homedir |
|
310 user = Etc.getpwuid( self.listdir.stat.uid ) |
|
311 return Pathname( user.dir ) |
|
312 end |
|
313 |
|
314 |
|
315 ### Safely make modifications to a file within a list directory. |
|
316 ### |
|
317 ### Mail can come in at any time. Make changes within a list |
|
318 ### atomic -- if an incoming message hits when a sticky |
|
319 ### is set, it is deferred to the Qmail queue. |
|
320 ### |
|
321 ### - Set sticky bit on the list directory owner's homedir |
|
322 ### - Make changes with the block |
|
323 ### - Unset sticky (just back to what it was previously) |
|
324 ### |
|
325 ### All writes should be wrapped in this method. |
|
326 ### |
|
327 def with_safety( &block ) |
|
328 home = self.homedir |
|
329 mode = home.stat.mode |
|
330 |
|
331 home.chmod( mode | 01000 ) # enable sticky |
|
332 yield |
|
333 |
|
334 ensure |
|
335 home.chmod( mode ) |
198 end |
336 end |
199 |
337 |
200 end # class Ezmlm::List |
338 end # class Ezmlm::List |
201 |
339 |