]> err.no Git - scalable-opengroupware.org/blob - SOGo/SoObjects/Mailer/SOGoMailManager.m
added methods to retrieve full folder hierarchy
[scalable-opengroupware.org] / SOGo / SoObjects / Mailer / SOGoMailManager.m
1 /*
2   Copyright (C) 2004 SKYRIX Software AG
3
4   This file is part of OpenGroupware.org.
5
6   OGo is free software; you can redistribute it and/or modify it under
7   the terms of the GNU Lesser General Public License as published by the
8   Free Software Foundation; either version 2, or (at your option) any
9   later version.
10
11   OGo is distributed in the hope that it will be useful, but WITHOUT ANY
12   WARRANTY; without even the implied warranty of MERCHANTABILITY or
13   FITNESS FOR A PARTICULAR PURPOSE.  See the GNU Lesser General Public
14   License for more details.
15
16   You should have received a copy of the GNU Lesser General Public
17   License along with OGo; see the file COPYING.  If not, write to the
18   Free Software Foundation, 59 Temple Place - Suite 330, Boston, MA
19   02111-1307, USA.
20 */
21
22 #include "SOGoMailManager.h"
23 #include "SOGoMailConnectionEntry.h"
24 #include "common.h"
25
26 // TODO: need a way to refresh caches on get mail!
27
28 @implementation SOGoMailManager
29
30 static BOOL           debugOn    = NO;
31 static BOOL           debugCache = NO;
32 static BOOL           debugKeys  = NO;
33 static BOOL           poolingOff = NO;
34 static NSTimeInterval PoolScanInterval = 5 * 60;
35 static NSString       *imap4Separator  = nil;
36
37 + (void)initialize {
38   NSUserDefaults *ud = [NSUserDefaults standardUserDefaults];
39   
40   debugOn    = [ud boolForKey:@"SOGoEnableIMAP4Debug"];
41   debugCache = [ud boolForKey:@"SOGoEnableIMAP4CacheDebug"];
42   poolingOff = [ud boolForKey:@"SOGoDisableIMAP4Pooling"];
43
44   if (debugOn)    NSLog(@"Note: SOGoEnableIMAP4Debug is enabled!");
45   if (poolingOff) NSLog(@"WARNING: IMAP4 connection pooling is disabled!");
46   
47   imap4Separator = [[ud stringForKey:@"SOGoIMAP4StringSeparator"] copy];
48   if ([imap4Separator length] == 0)
49     imap4Separator = @"/";
50   NSLog(@"Note(SOGoMailManager): using '%@' as the IMAP4 folder separator.", 
51         imap4Separator);
52 }
53
54 + (id)defaultMailManager {
55   static SOGoMailManager *manager = nil; // THREAD
56   if (manager == nil) 
57     manager = [[self alloc] init];
58   return manager;
59 }
60
61 - (id)init {
62   if ((self = [super init])) {
63     if (!poolingOff) {
64       self->urlToEntry = [[NSMutableDictionary alloc] initWithCapacity:256];
65     }
66     
67     self->gcTimer = [[NSTimer scheduledTimerWithTimeInterval:
68                                 PoolScanInterval
69                               target:self selector:@selector(_garbageCollect:)
70                               userInfo:nil repeats:YES] retain];
71   }
72   return self;
73 }
74
75 - (void)dealloc {
76   if (self->gcTimer) [self->gcTimer invalidate];
77   [self->gcTimer release];
78   
79   [self->urlToEntry release];
80   [super dealloc];
81 }
82
83 /* cache */
84
85 - (id)cacheKeyForURL:(NSURL *)_url {
86   // protocol, user, host, port
87   return [NSString stringWithFormat:@"%@://%@@%@:%@",
88                    [_url scheme], [_url user], [_url host], [_url port]];
89 }
90
91 - (SOGoMailConnectionEntry *)entryForURL:(NSURL *)_url {
92   if (_url == nil)
93     return nil;
94   
95   return [self->urlToEntry objectForKey:[self cacheKeyForURL:_url]];
96 }
97 - (void)cacheEntry:(SOGoMailConnectionEntry *)_entry forURL:(NSURL *)_url {
98   if (_entry == nil) _entry = (id)[NSNull null];
99   [self->urlToEntry setObject:_entry forKey:[self cacheKeyForURL:_url]];
100 }
101
102 - (void)_garbageCollect:(NSTimer *)_timer {
103   // TODO: scan for old IMAP4 channels
104   [self debugWithFormat:@"should collect IMAP4 channels (%d active)",
105           [self->urlToEntry count]];
106 }
107
108 - (id)entryForURL:(NSURL *)_url password:(NSString *)_pwd {
109   /*
110     Three cases:
111     a) not yet connected             => create new entry and connect
112     b) connected, correct password   => return cached entry
113     c) connected, different password => try to recreate entry
114   */
115   SOGoMailConnectionEntry *entry;
116   NGImap4Client *client;
117
118   /* check cache */
119   
120   if ((entry = [self entryForURL:_url]) != nil) {
121     if ([entry isValidPassword:_pwd]) {
122       if (debugCache)
123         [self logWithFormat:@"valid password, reusing cache entry ..."];
124       return entry;
125     }
126     
127     /* different password, password could have changed! */
128     if (debugCache)
129       [self logWithFormat:@"different password than cached entry: %@", _url];
130     entry = nil;
131   }
132   else
133     [self debugWithFormat:@"no connection cached yet for url: %@", _url];
134   
135   /* try to login */
136   
137   client = [entry isValidPassword:_pwd]
138     ? [entry client]
139     : [self imap4ClientForURL:_url password:_pwd];
140   
141   if (client == nil)
142     return nil;
143   
144   /* sideeffect of -imap4ClientForURL:password: is to create a cache entry */
145   return [self entryForURL:_url];
146 }
147
148 /* client object */
149
150 - (NGImap4Client *)imap4ClientForURL:(NSURL *)_url password:(NSString *)_pwd {
151   // TODO: move to some global IMAP4 connection pool manager
152   SOGoMailConnectionEntry *entry;
153   NGImap4Client *client;
154   NSDictionary  *result;
155   
156   if (_url == nil)
157     return nil;
158
159   /* check connection pool */
160   
161   if ((entry = [self entryForURL:_url]) != nil) {
162     if ([entry isValidPassword:_pwd]) {
163       [self debugWithFormat:@"reused IMAP4 connection for URL: %@", _url];
164       return [entry client];
165     }
166     
167     /* different password, password could have changed! */
168     entry = nil;
169   }
170   
171   /* setup connection and attempt login */
172   
173   if ((client = [NGImap4Client clientWithURL:_url]) == nil)
174     return nil;
175   
176   result = [client login:[_url user] password:_pwd];
177   if (![[result valueForKey:@"result"] boolValue]) {
178     [self logWithFormat:
179             @"ERROR: IMAP4 login failed "
180             @"(host=%@,user=%@,pwd=%s,url=%@/%@/%@): "
181             @"%@", 
182             [_url host], [_url user], [_pwd length] > 0 ? "yes" : "no", 
183             [_url absoluteString], [_url baseURL],
184             NSStringFromClass([[_url baseURL] class]),
185             client];
186     return nil;
187   }
188   
189   [self debugWithFormat:@"created new IMAP4 connection for URL: %@", _url];
190   
191   /* cache connection in pool */
192   
193   entry = [[SOGoMailConnectionEntry alloc] initWithClient:client 
194                                            password:_pwd];
195   [self cacheEntry:entry forURL:_url];
196   [entry release]; entry = nil;
197   
198   return client;
199 }
200
201 - (void)flushCachesForURL:(NSURL *)_url {
202   SOGoMailConnectionEntry *entry;
203   
204   if ((entry = [self entryForURL:_url]) == nil) /* nothing cached */
205     return;
206   
207   [entry flushFolderHierarchyCache];
208   [entry flushMailCaches];
209 }
210
211 /* folder hierarchy */
212
213 - (NSArray *)_getDirectChildren:(NSArray *)_array folderName:(NSString *)_fn {
214   /*
215     Scans string '_array' for strings which start with the string in '_fn'.
216     Then split on '/'.
217   */
218   NSMutableArray *ma;
219   unsigned i, count, prefixlen;
220   
221   if ((count = [_array count]) < 2)
222     /* one entry is the folder itself, so we need at least two */
223     return [NSArray array];
224   
225   prefixlen = [_fn length] + 1;
226   ma = [NSMutableArray arrayWithCapacity:count];
227   for (i = 0; i < count; i++) {
228     NSString *p;
229     
230     p = [_array objectAtIndex:i];
231     if ([p length] <= prefixlen)
232       continue;
233     if (![p hasPrefix:_fn])
234       continue;
235     
236     /* cut of common part */
237     p = [p substringFromIndex:prefixlen];
238     
239     /* check whether the path is a sub-subfolder path */
240     if ([p rangeOfString:@"/"].length > 0)
241       continue;
242     
243     [ma addObject:p];
244   }
245   
246   [ma sortUsingSelector:@selector(compare:)];
247   return ma;
248 }
249
250 - (NSString *)imap4Separator {
251   return imap4Separator;
252 }
253
254 - (NSString *)imap4FolderNameForURL:(NSURL *)_url removeFileName:(BOOL)_delfn {
255   /* a bit hackish, but should be OK */
256   NSString *folderName;
257   NSArray  *names;
258
259   if (_url == nil)
260     return nil;
261   
262   folderName = [_url path];
263   if ([folderName length] == 0)
264     return nil;
265   if ([folderName characterAtIndex:0] == '/')
266     folderName = [folderName substringFromIndex:1];
267   
268   if (_delfn) folderName = [folderName stringByDeletingLastPathComponent];
269   
270   if ([[self imap4Separator] isEqualToString:@"/"])
271     return folderName;
272   
273   names = [folderName pathComponents];
274   return [names componentsJoinedByString:[self imap4Separator]];
275 }
276 - (NSString *)imap4FolderNameForURL:(NSURL *)_url {
277   return [self imap4FolderNameForURL:_url removeFileName:NO];
278 }
279
280 - (NSArray *)extractSubfoldersForURL:(NSURL *)_url
281   fromResultSet:(NSDictionary *)_result
282 {
283   NSString     *folderName;
284   NSDictionary *result;
285   NSArray      *names;
286   NSArray      *flags;
287   
288   /* Note: the result is normalized, that is, it contains / as the separator */
289   folderName = [_url path];
290 #if __APPLE__ 
291   /* normalized results already have the / in front on libFoundation?! */
292   if ([folderName hasPrefix:@"/"]) 
293     folderName = [folderName substringFromIndex:1];
294 #endif
295   
296   result = [_result valueForKey:@"list"];
297   
298   /* Cyrus already tells us whether we need to check for children */
299   flags = [result objectForKey:folderName];
300   if ([flags containsObject:@"hasnochildren"]) {
301     if (debugKeys)
302       [self logWithFormat:@"folder %@ has no children.", folderName];
303     return nil;
304   }
305
306   if (debugKeys)
307     [self logWithFormat:@"all keys %@: %@", folderName, [result allKeys]];
308   
309   names = [self _getDirectChildren:[result allKeys] folderName:folderName];
310   if (debugKeys) {
311     [self debugWithFormat:@"subfolders of %@: %@", folderName, 
312             [names componentsJoinedByString:@","]];
313   }
314   return names;
315 }
316
317 - (NSArray *)extractFoldersFromResultSet:(NSDictionary *)_result {
318   /* Note: the result is normalized, that is, it contains / as the separator */
319   return [[_result valueForKey:@"list"] allKeys];
320 }
321
322 - (NSArray *)subfoldersForURL:(NSURL *)_url password:(NSString *)_pwd {
323   SOGoMailConnectionEntry *entry;
324   NSDictionary  *result;
325
326   if (debugKeys)
327     [self debugWithFormat:@"subfolders for URL: %@ ...",[_url absoluteString]];
328   
329   /* check connection cache */
330   
331   if ((entry = [self entryForURL:_url password:_pwd]) == nil)
332     return nil;
333   
334   /* check hierarchy cache */
335   
336   if ((result = [entry cachedHierarchyResults]) != nil)
337     return [self extractSubfoldersForURL:_url fromResultSet:result];
338   
339   [self debugWithFormat:@"  no folders cached yet .."];
340   
341   /* fetch _all_ folders */
342   
343   result = [[entry client] list:@"INBOX" pattern:@"*"];
344   if (![[result valueForKey:@"result"] boolValue]) {
345     [self logWithFormat:@"ERROR: listing of folder failed!"];
346     return nil;
347   }
348   
349   /* cache results */
350   
351   if ([result isNotNull]) {
352     if (entry == nil) /* required in case the entry was not setup */
353       entry = [self entryForURL:_url];
354     
355     [entry cacheHierarchyResults:result];
356     if (debugCache) {
357       [self logWithFormat:@"cached results in entry %@: 0x%08X(%d)", 
358               entry, result, [result count]];
359     }
360   }
361   
362   /* extract list */
363   
364   return [self extractSubfoldersForURL:_url fromResultSet:result];
365 }
366
367 - (NSArray *)allFoldersForURL:(NSURL *)_url password:(NSString *)_pwd {
368   SOGoMailConnectionEntry *entry;
369   NSDictionary  *result;
370
371   if (debugKeys)
372     [self debugWithFormat:@"folders for URL: %@ ...",[_url absoluteString]];
373   
374   /* check connection cache */
375   
376   if ((entry = [self entryForURL:_url password:_pwd]) == nil)
377     return nil;
378   
379   /* check hierarchy cache */
380   
381   if ((result = [entry cachedHierarchyResults]) != nil)
382     return [self extractFoldersFromResultSet:result];
383   
384   [self debugWithFormat:@"  no folders cached yet .."];
385   
386   /* fetch _all_ folders */
387   
388   result = [[entry client] list:@"INBOX" pattern:@"*"];
389   if (![[result valueForKey:@"result"] boolValue]) {
390     [self logWithFormat:@"ERROR: listing of folder failed!"];
391     return nil;
392   }
393   
394   /* cache results */
395   
396   if ([result isNotNull]) {
397     if (entry == nil) /* required in case the entry was not setup */
398       entry = [self entryForURL:_url];
399     
400     [entry cacheHierarchyResults:result];
401     if (debugCache) {
402       [self logWithFormat:@"cached results in entry %@: 0x%08X(%d)", 
403               entry, result, [result count]];
404     }
405   }
406   
407   /* extract list */
408   return [self extractFoldersFromResultSet:result];
409 }
410
411 /* messages */
412
413 - (NSArray *)fetchUIDsInURL:(NSURL *)_url qualifier:(id)_qualifier
414   sortOrdering:(id)_so password:(NSString *)_pwd
415 {
416   /* 
417      sortOrdering can be an NSString, an EOSortOrdering or an array of EOS.
418   */
419   SOGoMailConnectionEntry *entry;
420   NSDictionary  *result;
421   NSArray       *uids;
422   
423   /* check connection cache */
424   
425   if ((entry = [self entryForURL:_url password:_pwd]) == nil)
426     return nil;
427   
428   /* check cache */
429   
430   uids = [entry cachedUIDsForURL:_url qualifier:_qualifier sortOrdering:_so];
431   if (uids != nil) {
432     if (debugCache) [self logWithFormat:@"reusing uid cache!"];
433     return [uids isNotNull] ? uids : nil;
434   }
435   
436   /* select folder and fetch */
437   
438   result = [[entry client] select:[self imap4FolderNameForURL:_url]];
439   if (![[result valueForKey:@"result"] boolValue]) {
440     [self logWithFormat:@"ERROR: could not select URL: %@: %@", _url, result];
441     return nil;
442   }
443   
444   result = [[entry client] sort:_so qualifier:_qualifier encoding:@"UTF-8"];
445   if (![[result valueForKey:@"result"] boolValue]) {
446     [self logWithFormat:@"ERROR: could not sort contents of URL: %@", _url];
447     return nil;
448   }
449   
450   uids = [result valueForKey:@"sort"];
451   if (![uids isNotNull]) {
452     [self logWithFormat:@"ERROR: got no UIDs for URL: %@: %@", _url, result];
453     return nil;
454   }
455   
456   /* cache */
457   
458   [entry cacheUIDs:uids forURL:_url qualifier:_qualifier sortOrdering:_so];
459   return uids;
460 }
461
462 - (NSArray *)fetchUIDs:(NSArray *)_uids inURL:(NSURL *)_url
463   parts:(NSArray *)_parts password:(NSString *)_pwd
464 {
465   // currently returns a dict?!
466   /*
467     Allowed fetch keys:
468       UID
469       BODY.PEEK[<section>]<<partial>>
470       BODY            [this is the bodystructure, supported]
471       BODYSTRUCTURE   [not supported yet!]
472       ENVELOPE        [this is a parsed header, but does not include type]
473       FLAGS
474       INTERNALDATE
475       RFC822
476       RFC822.HEADER
477       RFC822.SIZE
478       RFC822.TEXT
479   */
480   NGImap4Client *client;
481   NSDictionary  *result;
482   
483   if (_uids == nil)
484     return nil;
485   if ([_uids count] == 0)
486     return nil; // TODO: might break empty folders?! return a dict!
487   
488   if ((client = [self imap4ClientForURL:_url password:_pwd]) == nil)
489     return nil;
490
491   /* select folder */
492   
493   result = [client select:[self imap4FolderNameForURL:_url]];
494   if (![[result valueForKey:@"result"] boolValue]) {
495     [self logWithFormat:@"ERROR: could not select URL: %@: %@", _url, result];
496     return nil;
497   }
498   
499   /* fetch parts */
500   
501   // TODO: split uids into batches, otherwise Cyrus will complain
502   //       => not really important because we batch before (in the sort)
503   //       if the list is too long, we get a:
504   //       "* BYE Fatal error: word too long"
505   
506   result = [client fetchUids:_uids parts:_parts];
507   if (![[result valueForKey:@"result"] boolValue]) {
508     [self logWithFormat:@"ERROR: could not fetch %d uids for url: %@",
509             [_uids count],_url];
510     return nil;
511   }
512   
513   //[self logWithFormat:@"RESULT: %@", result];
514   return (id)result;
515 }
516
517 - (id)fetchURL:(NSURL *)_url parts:(NSArray *)_parts password:(NSString *)_pwd{
518   // currently returns a dict
519   NGImap4Client *client;
520   NSDictionary  *result;
521   NSString *uid;
522   
523   if (![_url isNotNull]) return nil;
524   
525   if ((client = [self imap4ClientForURL:_url password:_pwd]) == nil)
526     return nil;
527   
528   /* select folder */
529   
530   result = [client select:[self imap4FolderNameForURL:_url
531                                 removeFileName:YES]];
532   if (![[result valueForKey:@"result"] boolValue]) {
533     [self logWithFormat:@"ERROR: could not select URL: %@: %@", _url, result];
534     return nil;
535   }
536   
537   /* fetch parts */
538   
539   uid = [[_url path] lastPathComponent];
540   
541   result = [client fetchUids:[NSArray arrayWithObject:uid] parts:_parts];
542   if (![[result valueForKey:@"result"] boolValue]) {
543     [self logWithFormat:@"ERROR: could not fetch url: %@", _url];
544     return nil;
545   }
546   //[self logWithFormat:@"RESULT: %@", result];
547   return (id)result;
548 }
549
550 - (NSData *)fetchContentOfBodyPart:(NSString *)_partId
551   atURL:(NSURL *)_url password:(NSString *)_pwd
552 {
553   NSString *key;
554   NSArray  *parts;
555   id result, fetch, body;
556   
557   if (_partId == nil) return nil;
558   
559   key   = [@"body[" stringByAppendingString:_partId];
560   key   = [key stringByAppendingString:@"]"];
561   parts = [NSArray arrayWithObjects:&key count:1];
562   
563   /* fetch */
564   
565   result = [self fetchURL:_url parts:parts password:_pwd];
566   
567   /* process results */
568   
569   result = [result objectForKey:@"fetch"];
570   if ([result count] == 0) { /* did not find part */
571     [self logWithFormat:@"ERROR: did not find part: %@", _partId];
572     return nil;
573   }
574   
575   fetch = [result objectAtIndex:0];
576   if ((body = [fetch objectForKey:@"body"]) == nil) {
577     [self logWithFormat:@"ERROR: did not find body in response: %@", result];
578     return nil;
579   }
580   
581   if ((result = [body objectForKey:@"data"]) == nil) {
582     [self logWithFormat:@"ERROR: did not find data in body: %@", fetch];
583     return nil;
584   }
585   return result;
586 }
587
588 - (NSException *)addOrRemove:(BOOL)_flag flags:(id)_f
589   toURL:(NSURL *)_url password:(NSString *)_p
590 {
591   NGImap4Client *client;
592   id result;
593   
594   if (![_url isNotNull]) return nil;
595   if (![_f   isNotNull]) return nil;
596   
597   if ((client = [self imap4ClientForURL:_url password:_p]) == nil)
598     return nil;
599   
600   if (![_f isKindOfClass:[NSArray class]])
601     _f = [NSArray arrayWithObjects:&_f count:1];
602   
603   result = [client storeUid:[[[_url path] lastPathComponent] intValue]
604                    add:[NSNumber numberWithBool:_flag]
605                    flags:_f];
606   if (![[result valueForKey:@"result"] boolValue]) {
607     [self logWithFormat:@"DEBUG: fail result %@", result];
608     return [NSException exceptionWithHTTPStatus:500 /* server error */
609                         reason:@"failed to add flag to IMAP4 message"];
610   }
611   /* result contains 'fetch' key with the current flags */
612   return nil;
613 }
614 - (NSException *)addFlags:(id)_f toURL:(NSURL *)_u password:(NSString *)_p {
615   return [self addOrRemove:YES flags:_f toURL:_u password:_p];
616 }
617 - (NSException *)removeFlags:(id)_f toURL:(NSURL *)_u password:(NSString *)_p {
618   return [self addOrRemove:NO flags:_f toURL:_u password:_p];
619 }
620
621 /* managing folders */
622
623 - (BOOL)isPermissionDeniedResult:(id)_result {
624   if ([[_result valueForKey:@"result"] intValue] != 0)
625     return NO;
626   
627   return [[_result valueForKey:@"reason"] 
628                    isEqualToString:@"Permission denied"];
629 }
630
631 - (NSException *)createMailbox:(NSString *)_mailbox atURL:(NSURL *)_url
632   password:(NSString *)_pwd
633 {
634   SOGoMailConnectionEntry *entry;
635   NSString *newPath;
636   id       result;
637
638   /* check connection cache */
639   
640   if ((entry = [self entryForURL:_url password:_pwd]) == nil) {
641     return [NSException exceptionWithHTTPStatus:404 /* Not Found */
642                         reason:@"did not find IMAP4 folder"];
643   }
644
645   /* construct path */
646   
647   newPath = [self imap4FolderNameForURL:_url];
648   newPath = [newPath stringByAppendingString:[self imap4Separator]];
649   newPath = [newPath stringByAppendingString:_mailbox];
650   
651   /* create */
652   
653   result = [[entry client] create:newPath];
654   if ([self isPermissionDeniedResult:result]) {
655     return [NSException exceptionWithHTTPStatus:403 /* forbidden */
656                         reason:@"creation of folders not allowed"];
657   }
658   else if ([[result valueForKey:@"result"] intValue] == 0) {
659     return [NSException exceptionWithHTTPStatus:500 /* server error */
660                         reason:[result valueForKey:@"reason"]];
661   }
662   
663   [entry flushFolderHierarchyCache];
664   // [self debugWithFormat:@"created mailbox: %@: %@", newPath, result];
665   return nil;
666 }
667
668 - (NSException *)deleteMailboxAtURL:(NSURL *)_url password:(NSString *)_pwd {
669   SOGoMailConnectionEntry *entry;
670   NSString *path;
671   id       result;
672
673   /* check connection cache */
674   
675   if ((entry = [self entryForURL:_url password:_pwd]) == nil) {
676     return [NSException exceptionWithHTTPStatus:404 /* Not Found */
677                         reason:@"did not find IMAP4 folder"];
678   }
679   
680   /* delete */
681   
682   path   = [self imap4FolderNameForURL:_url];
683   result = [[entry client] delete:path];
684   
685   if ([self isPermissionDeniedResult:result]) {
686     return [NSException exceptionWithHTTPStatus:403 /* forbidden */
687                         reason:@"creation of folders not allowed"];
688   }
689   else if ([[result valueForKey:@"result"] intValue] == 0) {
690     return [NSException exceptionWithHTTPStatus:500 /* server error */
691                         reason:[result valueForKey:@"reason"]];
692   }
693
694   [entry flushFolderHierarchyCache];
695 #if 0
696   [self debugWithFormat:@"delete mailbox %@: %@", _url, result];
697 #endif
698   return nil;
699 }
700
701 /* debugging */
702
703 - (BOOL)isDebuggingEnabled {
704   return debugOn;
705 }
706
707 @end /* SOGoMailManager */