2 Copyright (C) 2000-2007 SKYRIX Software AG
3 Copyright (C) 2007 Helge Hess
5 This file is part of SOPE.
7 SOPE is free software; you can redistribute it and/or modify it under
8 the terms of the GNU Lesser General Public License as published by the
9 Free Software Foundation; either version 2, or (at your option) any
12 SOPE is distributed in the hope that it will be useful, but WITHOUT ANY
13 WARRANTY; without even the implied warranty of MERCHANTABILITY or
14 FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public
15 License for more details.
17 You should have received a copy of the GNU Lesser General Public
18 License along with SOPE; see the file COPYING. If not, write to the
19 Free Software Foundation, 59 Temple Place - Suite 330, Boston, MA
25 #include "NGSieveClient.h"
26 #include "NGImap4Support.h"
27 #include "NGImap4ResponseParser.h"
28 #include "NSString+Imap4.h"
32 @interface NGSieveClient(Private)
34 - (NGHashMap *)processCommand:(id)_command;
35 - (NGHashMap *)processCommand:(id)_command logText:(id)_txt;
37 - (NSException *)sendCommand:(id)_command;
38 - (NSException *)sendCommand:(id)_command logText:(id)_txt;
39 - (NSException *)sendCommand:(id)_command logText:(id)_txt attempts:(int)_c;
41 - (NSMutableDictionary *)normalizeResponse:(NGHashMap *)_map;
42 - (NSMutableDictionary *)normalizeOpenConnectionResponse:(NGHashMap *)_map;
43 - (NSDictionary *)login;
47 - (NSString *)readStringToCRLF;
48 - (NSString *)readString;
53 An implementation of an Imap4 client
55 A folder name always looks like an absolute filename (/inbox/blah)
57 NOTE: Sieve is just the filtering language ...
60 http://asg.web.cmu.edu/rfc/rfc2244.html
63 "IMPLEMENTATION" "Cyrus timsieved v2.1.15-IPv6-Debian-2.1.15-0woody.1.0"
65 "SIEVE" "fileinto reject envelope vacation imapflags notify subaddress relational regex"
71 @implementation NGSieveClient
73 static int defaultSievePort = 2000;
74 static NSNumber *YesNumber = nil;
75 static NSNumber *NoNumber = nil;
76 static BOOL ProfileImapEnabled = NO;
77 static BOOL LOG_PASSWORD = NO;
78 static BOOL debugImap4 = NO;
81 static BOOL didInit = NO;
86 ud = [NSUserDefaults standardUserDefaults];
87 LOG_PASSWORD = [ud boolForKey:@"SieveLogPassword"];
88 ProfileImapEnabled = [ud boolForKey:@"ProfileImapEnabled"];
89 debugImap4 = [ud boolForKey:@"ImapDebugEnabled"];
91 YesNumber = [[NSNumber numberWithBool:YES] retain];
92 NoNumber = [[NSNumber numberWithBool:NO] retain];
95 + (id)clientWithURL:(id)_url {
96 return [[(NGSieveClient *)[self alloc] initWithURL:_url] autorelease];
99 + (id)clientWithAddress:(id<NGSocketAddress>)_address {
100 NGSieveClient *client;
102 client = [self alloc];
103 return [[client initWithAddress:_address] autorelease];
106 + (id)clientWithHost:(id)_host {
107 return [[[self alloc] initWithHost:_host] autorelease];
110 - (id)initWithNSURL:(NSURL *)_url {
111 NGInternetSocketAddress *a;
114 if ((port = [[_url port] intValue]) == 0)
115 port = defaultSievePort;
117 a = [NGInternetSocketAddress addressWithPort:port
119 if ((self = [self initWithAddress:a])) {
120 self->login = [[_url user] copy];
121 self->password = [[_url password] copy];
125 - (id)initWithURL:(id)_url {
131 if (![_url isKindOfClass:[NSURL class]])
132 _url = [NSURL URLWithString:[_url stringValue]];
134 return [self initWithNSURL:_url];
137 - (id)initWithHost:(id)_host {
138 NGInternetSocketAddress *a;
140 a = [NGInternetSocketAddress addressWithPort:defaultSievePort onHost:_host];
141 return [self initWithAddress:a];
144 - (id)initWithAddress:(id<NGSocketAddress>)_address { // di
145 if ((self = [super init])) {
146 self->address = [_address retain];
147 self->debug = debugImap4;
153 [self->lastException release];
154 [self->address release];
156 [self->socket release];
157 [self->parser release];
158 [self->login release];
159 [self->password release];
165 - (BOOL)isEqual:(id)_obj {
168 if ([_obj isKindOfClass:[NGSieveClient class]])
169 return [self isEqualToSieveClient:_obj];
173 - (BOOL)isEqualToSieveClient:(NGSieveClient *)_obj {
174 if (_obj == self) return YES;
175 if (_obj == nil) return NO;
176 return [[_obj address] isEqual:self->address];
181 - (id<NGActiveSocket>)socket {
185 - (id<NGSocketAddress>)address {
186 return self->address;
191 - (void)setLastException:(NSException *)_ex {
192 ASSIGN(self->lastException, _ex);
194 - (NSException *)lastException {
195 return self->lastException;
197 - (void)resetLastException {
198 [self->lastException release];
199 self->lastException = nil;
204 - (void)resetStreams {
205 [self->socket release]; self->socket = nil;
206 [self->io release]; self->io = nil;
207 [self->parser release]; self->parser = nil;
210 - (NSDictionary *)openConnection {
214 if (ProfileImapEnabled) {
215 gettimeofday(&tv, NULL);
216 ti = (double)tv.tv_sec + ((double)tv.tv_usec / 1000000.0);
222 [[NGActiveSocket socketConnectedToAddress:self->address] retain];
223 if (self->socket == nil) {
224 [self logWithFormat:@"ERROR: could not connect: %@", self->address];
228 self->io = [NGBufferedStream alloc]; // keep gcc happy
229 self->io = [self->io initWithSource:(id)self->socket];
230 self->parser = [[NGImap4ResponseParser alloc] initWithStream:self->socket];
232 /* receive greeting from server without tag-id */
234 if (ProfileImapEnabled) {
235 gettimeofday(&tv, NULL);
236 ti = (double)tv.tv_sec + ((double)tv.tv_usec / 1000000.0) - ti;
237 fprintf(stderr, "[%s] <openConnection> : time needed: %4.4fs\n",
238 __PRETTY_FUNCTION__, ti < 0.0 ? -1.0 : ti);
240 return [self normalizeOpenConnectionResponse:
241 [self->parser parseSieveResponse]];
244 - (NSNumber *)isConnected {
246 Check whether stream is already open (could be closed due to a server
249 // TODO: why does that return an object?
250 if (self->socket == nil)
251 return [NSNumber numberWithBool:NO];
253 return [NSNumber numberWithBool:[(NGActiveSocket *)self->socket isAlive]];
256 - (void)closeConnection {
257 [self->socket close];
258 [self->socket release]; self->socket = nil;
259 [self->parser release]; self->parser = nil;
262 - (NSDictionary *)login:(NSString *)_login password:(NSString *)_passwd {
263 /* login with plaintext password authenticating */
265 if ((_login == nil) || (_passwd == nil))
268 [self->login release]; self->login = nil;
269 [self->password release]; self->password = nil;
271 self->login = [_login copy];
272 self->password = [_passwd copy];
277 [self closeConnection];
278 [self openConnection];
282 - (NSDictionary *)login {
283 NGHashMap *map = nil;
288 if (![self->socket isConnected]) {
291 if ((con = [self openConnection]) == nil)
293 if (![[con objectForKey:@"result"] boolValue])
297 logLen = [self->login cStringLength];
298 bufLen = (logLen * 2) + [self->password cStringLength] +2;
300 buf = calloc(bufLen + 2, sizeof(char));
308 sprintf(buf, "%s %s %s",
309 [self->login cString], [self->login cString],
310 [self->password cString]);
313 buf[logLen * 2 + 1] = '\0';
315 auth = [NSData dataWithBytesNoCopy:buf length:bufLen];
316 auth = [auth dataByEncodingBase64WithLineLength:4096 /* 'unlimited' */];
321 s = [NSString stringWithFormat:@"AUTHENTICATE \"PLAIN\" {%d+}\r\n%s",
322 [auth length], [auth bytes]];
323 map = [self processCommand:s];
328 s = [NSString stringWithFormat:@"AUTHENTICATE \"PLAIN\" {%d+}\r\n%s",
329 [auth length], [auth bytes]];
330 map = [self processCommand:s
331 logText:@"AUTHENTICATE \"PLAIN\" {%d+}\r\nLOGIN:PASSWORD\r\n"];
335 [self logWithFormat:@"ERROR: got no result from command."];
339 return [self normalizeResponse:map];
342 /* logout from the connected host and close the connection */
344 - (NSDictionary *)logout {
347 map = [self processCommand:@"logout"]; // TODO: check for success!
348 [self closeConnection];
349 return [self normalizeResponse:map];
352 - (NSString *)getScript:(NSString *)_scriptName {
354 NSString *script, *s;
356 s = [@"GETSCRIPT \"" stringByAppendingString:_scriptName];
357 s = [s stringByAppendingString:@"\""];
358 ex = [self sendCommand:s logText:s attempts:3];
360 [self logWithFormat:@"ERROR: could not get script: %@", ex];
361 [self setLastException:ex];
365 /* read script string */
367 if ((script = [[self readString] autorelease]) == nil)
370 if ([script hasPrefix:@"O "] || [script hasPrefix:@"NO "]) {
371 // TODO: not exactly correct, script could begin with this signature
372 // Note: readString read 'NO ...', but the first char is consumed
374 [self logWithFormat:@"ERROR: status line reports: '%@'", script];
378 NSLog(@"str: %@", script);
380 /* read response code */
382 if ((s = [self readStringToCRLF]) == nil) {
383 [self logWithFormat:@"ERROR: could not parse status line."];
386 if (![s isNotEmpty]) { // remainder of previous string
388 if ((s = [self readStringToCRLF]) == nil) {
389 [self logWithFormat:@"ERROR: could not parse status line."];
394 if (![s hasPrefix:@"OK"]) {
395 [self logWithFormat:@"ERROR: status line reports: '%@'", s];
404 - (BOOL)isValidScriptName:(NSString *)_name {
405 return [_name isNotEmpty];
408 - (NSDictionary *)putScript:(NSString *)_name script:(NSString *)_script {
409 // TODO: script should be send in UTF-8!
413 if (![self isValidScriptName:_name]) {
414 [self logWithFormat:@"%s: missing script-name", __PRETTY_FUNCTION__];
417 if (![_script isNotEmpty]) {
418 [self logWithFormat:@"%s: missing script", __PRETTY_FUNCTION__];
423 s = [s stringByAppendingString:_name];
424 s = [s stringByAppendingString:@"\" "];
425 s = [s stringByAppendingFormat:@"{%d+}\r\n%@", [_script length], _script];
426 s = [s stringByAppendingString:@"\r\n"];
427 map = [self processCommand:s];
428 return [self normalizeResponse:map];
431 - (NSDictionary *)setActiveScript:(NSString *)_name {
434 if (![self isValidScriptName:_name]) {
435 NSLog(@"%s: missing script-name", __PRETTY_FUNCTION__);
438 map = [self processCommand:
439 [NSString stringWithFormat:@"SETACTIVE \"%@\"\r\n", _name]];
440 return [self normalizeResponse:map];
443 - (NSDictionary *)deleteScript:(NSString *)_name {
447 if (![self isValidScriptName:_name]) {
448 NSLog(@"%s: missing script-name", __PRETTY_FUNCTION__);
452 s = [NSString stringWithFormat:@"DELETESCRIPT \"%@\"\r\n", _name];
453 map = [self processCommand:s];
454 return [self normalizeResponse:map];
457 - (NSDictionary *)listScripts {
458 NSMutableDictionary *md;
462 ex = [self sendCommand:@"LISTSCRIPTS" logText:@"LISTSCRIPTS" attempts:3];
464 [self logWithFormat:@"ERROR: could not list scripts: %@", ex];
465 [self setLastException:ex];
471 md = [NSMutableDictionary dictionaryWithCapacity:16];
472 while ((line = [self readStringToCRLF]) != nil) {
473 if ([line hasPrefix:@"OK"])
476 if ([line hasPrefix:@"NO"]) {
481 if ([line hasPrefix:@"{"]) {
482 [self logWithFormat:@"unsupported list response line: '%@'", line];
484 else if ([line hasPrefix:@"\""]) {
489 s = [line substringFromIndex:1];
490 r = [s rangeOfString:@"\""];
493 [self logWithFormat:@"missing closing quote in line: '%@'", line];
494 [line release]; line = nil;
498 s = [s substringToIndex:r.location];
499 isActive = [line rangeOfString:@"ACTIVE"].length == 0 ? NO : YES;
501 [md setObject:isActive ? @"ACTIVE" : @"" forKey:s];
504 [self logWithFormat:@"unexpected list response line (%d): '%@'",
505 [line length], line];
508 [line release]; line = nil;
511 [line release]; line = nil;
517 - (NSMutableDictionary *)normalizeResponse:(NGHashMap *)_map {
519 Filter for all responses
520 result : NSNumber (response result)
521 exists : NSNumber (number of exists mails in selectet folder
522 recent : NSNumber (number of recent mails in selectet folder
523 expunge : NSArray (message sequence number of expunged mails
526 id keys[3], values[3];
527 NSParameterAssert(_map != nil);
529 keys[0] = @"RawResponse"; values[0] = _map;
531 values[1] = [[_map objectForKey:@"ok"] boolValue] ? YesNumber : NoNumber;
532 return [NSMutableDictionary dictionaryWithObjects:values
533 forKeys:keys count:2];
536 - (NSMutableDictionary *)normalizeOpenConnectionResponse:(NGHashMap *)_map {
537 /* filter for open connection */
538 NSMutableDictionary *result;
541 result = [self normalizeResponse:_map];
543 if (![[[_map objectEnumeratorForKey:@"ok"] nextObject] boolValue])
546 if ((tmp = [_map objectForKey:@"implementation"]))
547 [result setObject:tmp forKey:@"server"];
548 if ((tmp = [_map objectForKey:@"sieve"]))
549 [result setObject:tmp forKey:@"capabilities"];
553 /* Private Methods */
555 - (BOOL)handleProcessException:(NSException *)_exception
556 repetitionCount:(int)_cnt
563 if ([_exception isKindOfClass:[NGIOException class]]) {
565 @"WARNING: got exception try to restore connection: %@",
569 if ([_exception isKindOfClass:[NGImap4ParserException class]]) {
571 @"WARNING: Got Parser-Exception try to restore connection: %@",
580 - (void)waitPriorReconnectWithRepetitionCount:(int)_cnt {
584 [self logWithFormat:@"reconnect to %@, sleeping %d seconds ...",
585 self->address, timeout];
587 [self logWithFormat:@"reconnect ..."];
590 - (NGHashMap *)processCommand:(id)_command logText:(id)_txt {
591 NGHashMap *map = nil;
592 BOOL repeatCommand = NO;
597 if (ProfileImapEnabled) {
598 gettimeofday(&tv, NULL);
599 ti = (double)tv.tv_sec + ((double)tv.tv_usec / 1000000.0);
600 fprintf(stderr, "{");
602 do { /* TODO: shouldn't that be a while loop? */
605 [self waitPriorReconnectWithRepetitionCount:repeatCnt];
615 if ((ex = [self sendCommand:_command logText:_txt]) != nil) {
616 repeatCommand = [self handleProcessException:ex
617 repetitionCount:repeatCnt];
620 map = [self->parser parseSieveResponse];
623 repeatCommand = [self handleProcessException:localException
624 repetitionCount:repeatCnt];
628 while (repeatCommand);
630 if (ProfileImapEnabled) {
631 gettimeofday(&tv, NULL);
632 ti = (double)tv.tv_sec + ((double)tv.tv_usec / 1000000.0) - ti;
633 fprintf(stderr, "}[%s] <Send Command> : time needed: %4.4fs\n",
634 __PRETTY_FUNCTION__, ti < 0.0 ? -1.0 : ti);
640 - (NGHashMap *)processCommand:(id)_command {
641 return [self processCommand:_command logText:_command];
644 - (NSException *)sendCommand:(id)_command logText:(id)_txt {
645 NSString *command = nil;
647 if ((command = _command) == nil) /* missing command */
648 return nil; // TODO: return exception?
653 if ([_txt isKindOfClass:[NSData class]]) {
654 fprintf(stderr, "C: ");
655 fwrite([_txt bytes], [_txt length], 1, stderr);
659 fprintf(stderr, "C: %s\n", [_txt cString]);
664 if (![_command isKindOfClass:[NSData class]])
665 _command = [command dataUsingEncoding:NSUTF8StringEncoding];
667 if (![self->io safeWriteData:_command])
668 return [self->io lastException];
669 if (![self->io writeBytes:"\r\n" count:2])
670 return [self->io lastException];
671 if (![self->io flush])
672 return [self->io lastException];
677 - (NSException *)sendCommand:(id)_command {
678 return [self sendCommand:_command logText:_command];
681 - (NSException *)sendCommand:(id)_command logText:(id)_txt attempts:(int)_c {
686 for (tryAgain = YES, repeatCnt = 0, ex = nil; tryAgain; repeatCnt++) {
688 if (repeatCnt > 1) /* one repeat goes without delay */
689 [self waitPriorReconnectWithRepetitionCount:repeatCnt];
695 ex = [self sendCommand:_command logText:_txt];
697 ex = [localException retain];
700 if (ex == nil) /* everything is fine */
703 if (repeatCnt > _c) /* reached max attempts */
706 /* try again for certain exceptions */
707 tryAgain = [self handleProcessException:ex repetitionCount:repeatCnt];
718 if (![self->io readBytes:&c count:1]) {
719 [self setLastException:[self->io lastException]];
725 - (NSString *)readLiteral {
727 Assumes 1st char is consumed, returns a retained string.
729 Parses: "{" number [ "+" ] "}" CRLF *OCTET
731 unsigned char countBuf[16];
734 unsigned char *octets;
738 for (i = 0; i < 14; i++) {
741 if ((c = [self readByte]) == -1)
749 byteCount = i > 0 ? atoi((char *)countBuf) : 0;
755 if (i == '\r' && i != -1)
766 octets = malloc(byteCount + 4);
767 if (![self->io safeReadBytes:octets count:byteCount]) {
768 [self setLastException:[self->io lastException]];
771 octets[byteCount] = '\0';
773 return [[NSString alloc] initWithUTF8String:(char *)octets];
776 - (NSString *)readQuoted {
778 assumes 1st char is consumed, returns a retained string
780 Note: quoted strings are limited to 1KB!
782 unsigned char buf[1032];
791 while ((c != -1) && (c != '"'));
797 return [[NSString alloc] initWithUTF8String:(char *)buf];
800 - (NSString *)readStringToCRLF {
801 unsigned char buf[1032];
807 if (c == '\n' || c == '\r')
813 while ((c != -1) && (c != '\r') && (c != '\n') && (i < 1024));
821 if ((c = [self readByte]) != '\n') {
824 [self logWithFormat:@"WARNING(%s): expected LF after CR, got: '%c'",
825 __PRETTY_FUNCTION__, c];
830 return [[NSString alloc] initWithUTF8String:(char *)buf];
833 - (NSString *)readString {
834 /* Note: returns a retained string */
837 if ((c1 = [self readByte]) == -1)
841 return [self readQuoted];
843 return [self readLiteral];
845 // Note: this does not return the first char!
846 return [self readStringToCRLF];
849 - (NSString *)readSieveName {
850 return [self readString];
855 - (NSString *)description {
858 ms = [NSMutableString stringWithCapacity:128];
859 [ms appendFormat:@"<0x%p[%@]:", self, NSStringFromClass([self class])];
861 if (self->socket != nil)
862 [ms appendFormat:@" socket=%@", [self socket]];
864 [ms appendFormat:@" address=%@", self->address];
866 [ms appendString:@">"];
870 @end /* NGSieveClient */