2 Copyright (C) 2004-2005 SKYRIX Software AG
4 This file is part of OpenGroupware.org.
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
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.
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
22 #include "SOGoMailObject.h"
23 #include "SOGoMailFolder.h"
24 #include "SOGoMailAccount.h"
25 #include "SOGoMailManager.h"
26 #include "SOGoMailBodyPart.h"
27 #include <NGImap4/NGImap4Envelope.h>
28 #include <NGImap4/NGImap4EnvelopeAddress.h>
29 #include <NGMail/NGMimeMessageParser.h>
32 @implementation SOGoMailObject
34 static NSArray *coreInfoKeys = nil;
35 static NSString *mailETag = nil;
36 static BOOL heavyDebug = NO;
37 static BOOL fetchHeader = YES;
38 static BOOL debugOn = NO;
39 static BOOL debugBodyStructure = NO;
40 static BOOL debugSoParts = NO;
43 return [super version] + 0 /* v1 */;
47 NSUserDefaults *ud = [NSUserDefaults standardUserDefaults];
49 NSAssert2([super version] == 1,
50 @"invalid superclass (%@) version %i !",
51 NSStringFromClass([self superclass]), [super version]);
53 if ((fetchHeader = ([ud boolForKey:@"SOGoDoNotFetchMailHeader"] ? NO : YES)))
54 NSLog(@"Note: fetching full mail header.");
56 NSLog(@"Note: not fetching full mail header: 'SOGoDoNotFetchMailHeader'");
58 /* Note: see SOGoMailManager.m for allowed IMAP4 keys */
59 /* Note: "BODY" actually returns the structure! */
61 coreInfoKeys = [[NSArray alloc] initWithObjects:
62 @"FLAGS", @"ENVELOPE", @"BODY",
65 // not yet supported: @"INTERNALDATE",
69 coreInfoKeys = [[NSArray alloc] initWithObjects:
70 @"FLAGS", @"ENVELOPE", @"BODY",
72 // not yet supported: @"INTERNALDATE",
76 mailETag = [[NSString alloc] initWithFormat:@"\"imap4url_%d_%d_%03d\"",
77 UIX_MAILER_MAJOR_VERSION,
78 UIX_MAILER_MINOR_VERSION,
79 UIX_MAILER_SUBMINOR_VERSION];
80 NSLog(@"Note(SOGoMailObject): using constant etag for mail parts: '%@'",
85 [self->headers release];
86 [self->headerPart release];
87 [self->coreInfos release];
93 - (NSString *)relativeImap4Name {
94 return [[self nameInContainer] stringByDeletingPathExtension];
99 - (SOGoMailObject *)mailObject {
105 - (NSString *)keyExtensionForPart:(id)_partInfo {
108 if (_partInfo == nil)
111 mt = [_partInfo valueForKey:@"type"];
112 st = [[_partInfo valueForKey:@"subtype"] lowercaseString];
113 if ([mt isEqualToString:@"text"]) {
114 if ([st isEqualToString:@"plain"]) return @".txt";
115 if ([st isEqualToString:@"html"]) return @".html";
116 if ([st isEqualToString:@"calendar"]) return @".ics";
117 if ([st isEqualToString:@"x-vcard"]) return @".vcf";
119 else if ([mt isEqualToString:@"image"])
120 return [@"." stringByAppendingString:st];
121 else if ([mt isEqualToString:@"application"]) {
122 if ([st isEqualToString:@"pgp-signature"])
129 - (NSArray *)relationshipKeysWithParts:(BOOL)_withParts {
130 /* should return non-multipart children */
135 parts = [[self bodyStructure] valueForKey:@"parts"];
136 if (![parts isNotNull])
138 if ((count = [parts count]) == 0)
141 for (i = 0, ma = nil; i < count; i++) {
146 part = [parts objectAtIndex:i];
147 hasParts = [part valueForKey:@"parts"] != nil ? YES:NO;
148 if ((hasParts && !_withParts) || (_withParts && !hasParts))
152 ma = [NSMutableArray arrayWithCapacity:count - i];
154 ext = [self keyExtensionForPart:part];
155 key = [[NSString alloc] initWithFormat:@"%d%@", i + 1, ext?ext:@""];
162 - (NSArray *)toOneRelationshipKeys {
163 return [self relationshipKeysWithParts:NO];
165 - (NSArray *)toManyRelationshipKeys {
166 return [self relationshipKeysWithParts:YES];
171 - (id)fetchParts:(NSArray *)_parts {
172 return [[self imap4Connection] fetchURL:[self imap4URL] parts:_parts];
177 - (BOOL)doesMailExist {
178 static NSArray *existsKey = nil;
181 if (self->coreInfos != nil) /* if we have coreinfos, we can use them */
182 return [self->coreInfos isNotNull];
184 /* otherwise fetch something really simple */
186 if (existsKey == nil) /* we use size, other suggestions? */
187 existsKey = [[NSArray alloc] initWithObjects:@"RFC822.SIZE", nil];
189 msgs = [self fetchParts:existsKey]; // returns dict
190 msgs = [msgs valueForKey:@"fetch"];
191 return [msgs count] > 0 ? YES : NO;
194 - (id)fetchCoreInfos {
197 if (self->coreInfos != nil)
198 return [self->coreInfos isNotNull] ? self->coreInfos : nil;
200 #if 0 // TODO: old code, why was it using clientObject??
201 msgs = [[self clientObject] fetchParts:coreInfoKeys]; // returns dict
203 msgs = [self fetchParts:coreInfoKeys]; // returns dict
205 if (heavyDebug) [self logWithFormat:@"M: %@", msgs];
206 msgs = [msgs valueForKey:@"fetch"];
207 if ([msgs count] == 0)
210 self->coreInfos = [[msgs objectAtIndex:0] retain];
211 return self->coreInfos;
214 - (id)bodyStructure {
217 body = [[self fetchCoreInfos] valueForKey:@"body"];
218 if (debugBodyStructure)
219 [self logWithFormat:@"BODY: %@", body];
223 - (NGImap4Envelope *)envelope {
224 return [[self fetchCoreInfos] valueForKey:@"envelope"];
226 - (NSString *)subject {
227 return [[self envelope] subject];
229 - (NSCalendarDate *)date {
230 return [[self envelope] date];
232 - (NSArray *)fromEnvelopeAddresses {
233 return [[self envelope] from];
235 - (NSArray *)toEnvelopeAddresses {
236 return [[self envelope] to];
238 - (NSArray *)ccEnvelopeAddresses {
239 return [[self envelope] cc];
242 - (NSData *)mailHeaderData {
243 return [[self fetchCoreInfos] valueForKey:@"header"];
245 - (BOOL)hasMailHeaderInCoreInfos {
246 return [[self mailHeaderData] length] > 0 ? YES : NO;
249 - (id)mailHeaderPart {
250 NGMimeMessageParser *parser;
253 if (self->headerPart != nil)
254 return [self->headerPart isNotNull] ? self->headerPart : nil;
256 if ([(data = [self mailHeaderData]) length] == 0)
259 // TODO: do we need to set some delegate method which stops parsing the body?
260 parser = [[NGMimeMessageParser alloc] init];
261 self->headerPart = [[parser parsePartFromData:data] retain];
262 [parser release]; parser = nil;
264 if (self->headerPart == nil) {
265 self->headerPart = [[NSNull null] retain];
268 return self->headerPart;
270 - (NSDictionary *)mailHeaders {
271 if (self->headers == nil)
272 self->headers = [[[self mailHeaderPart] headers] copy];
273 return self->headers;
276 - (id)lookupInfoForBodyPart:(id)_path {
281 if (![_path isNotNull])
284 if ((info = [self bodyStructure]) == nil) {
285 [self errorWithFormat:@"got no body part structure!"];
289 /* ensure array argument */
291 if ([_path isKindOfClass:[NSString class]]) {
292 if ([_path length] == 0)
295 _path = [_path componentsSeparatedByString:@"."];
299 For each path component, eg 1,1,3
301 Remember that we need special processing for message/rfc822 which maps the
302 namespace of multiparts directly into the main namespace.
304 TODO(hh): no I don't remember, please explain in more detail!
306 pe = [_path objectEnumerator];
307 while ((p = [pe nextObject]) != nil && [info isNotNull]) {
312 [self debugWithFormat:@"check PATH: %@", p];
313 idx = [p intValue] - 1;
315 parts = [info valueForKey:@"parts"];
316 mt = [[info valueForKey:@"type"] lowercaseString];
317 if ([mt isEqualToString:@"message"]) {
318 /* we have special behaviour for message types */
321 if ((body = [info valueForKey:@"body"]) != nil) {
322 mt = [body valueForKey:@"type"];
323 if ([mt isEqualToString:@"multipart"])
324 parts = [body valueForKey:@"parts"];
326 parts = [NSArray arrayWithObject:body];
330 if (idx >= [parts count]) {
331 [self errorWithFormat:
332 @"body part index out of bounds(idx=%d vs count=%d): %@",
333 (idx + 1), [parts count], info];
336 info = [parts objectAtIndex:idx];
338 return [info isNotNull] ? info : nil;
343 - (NSData *)content {
345 id result, fullResult;
347 fullResult = [self fetchParts:[NSArray arrayWithObject:@"RFC822"]];
348 if (fullResult == nil)
351 if ([fullResult isKindOfClass:[NSException class]])
354 /* extract fetch result */
356 result = [fullResult valueForKey:@"fetch"];
357 if (![result isKindOfClass:[NSArray class]]) {
359 @"ERROR: unexpected IMAP4 result (missing 'fetch'): %@",
361 return [NSException exceptionWithHTTPStatus:500 /* server error */
362 reason:@"unexpected IMAP4 result"];
364 if ([result count] == 0)
367 result = [result objectAtIndex:0];
369 /* extract message */
371 if ((content = [result valueForKey:@"message"]) == nil) {
373 @"ERROR: unexpected IMAP4 result (missing 'message'): %@",
375 return [NSException exceptionWithHTTPStatus:500 /* server error */
376 reason:@"unexpected IMAP4 result"];
379 return [[content copy] autorelease];
382 - (NSString *)contentAsString {
386 if ((content = [self content]) == nil)
388 if ([content isKindOfClass:[NSException class]])
391 s = [[NSString alloc] initWithData:content
392 encoding:NSISOLatin1StringEncoding];
395 @"ERROR: could not convert data of length %d to string",
399 return [s autorelease];
402 /* bulk fetching of plain/text content */
404 - (BOOL)shouldFetchPartOfType:(NSString *)_type subtype:(NSString *)_subtype {
405 _type = [_type lowercaseString];
406 _subtype = [_subtype lowercaseString];
408 if ([_type isEqualToString:@"text"]) {
409 if ([_subtype isEqualToString:@"plain"])
412 if ([_subtype isEqualToString:@"calendar"]) /* we also fetch calendars */
416 if ([_type isEqualToString:@"application"]) {
417 if ([_subtype isEqualToString:@"pgp-signature"])
424 - (void)addRequiredKeysOfStructure:(id)_info path:(NSString *)_p
425 toArray:(NSMutableArray *)_keys
426 recurse:(BOOL)_recurse
433 fetchPart = [self shouldFetchPartOfType:[_info valueForKey:@"type"]
434 subtype:[_info valueForKey:@"subtype"]];
438 if ([_p length] > 0) {
439 k = [[@"body[" stringByAppendingString:_p] stringByAppendingString:@"]"];
443 for some reason we need to add ".TEXT" for plain text stuff on root
445 TODO: check with HTML
457 parts = [(NSDictionary *)_info objectForKey:@"parts"];
458 for (i = 0, count = [parts count]; i < count; i++) {
463 ? [_p stringByAppendingFormat:@".%d", i + 1]
464 : [NSString stringWithFormat:@"%d", i + 1];
466 childInfo = [parts objectAtIndex:i];
468 [self addRequiredKeysOfStructure:childInfo path:sp toArray:_keys
474 if ((body = [(NSDictionary *)_info objectForKey:@"body"]) != nil) {
477 sp = [[body valueForKey:@"type"] lowercaseString];
478 if ([sp isEqualToString:@"multipart"])
481 sp = [_p length] > 0 ? [_p stringByAppendingString:@".1"] : @"1";
482 [self addRequiredKeysOfStructure:body path:sp toArray:_keys
487 - (NSArray *)plainTextContentFetchKeys {
490 ma = [NSMutableArray arrayWithCapacity:4];
491 [self addRequiredKeysOfStructure:[[self clientObject] bodyStructure]
492 path:@"" toArray:ma recurse:YES];
496 - (NSDictionary *)fetchPlainTextParts:(NSArray *)_fetchKeys {
497 NSMutableDictionary *flatContents;
501 [self debugWithFormat:@"fetch keys: %@", _fetchKeys];
503 result = [self fetchParts:_fetchKeys];
504 result = [result valueForKey:@"RawResponse"]; // hackish
506 // Note: -valueForKey: doesn't work!
507 result = [(NSDictionary *)result objectForKey:@"fetch"];
509 count = [_fetchKeys count];
510 flatContents = [NSMutableDictionary dictionaryWithCapacity:count];
511 for (i = 0; i < count; i++) {
515 key = [_fetchKeys objectAtIndex:i];
516 data = [(NSDictionary *)[(NSDictionary *)result objectForKey:key]
517 objectForKey:@"data"];
519 if (![data isNotNull]) {
520 [self debugWithFormat:@"got no data fork key: %@", key];
524 if ([key isEqualToString:@"body[text]"])
525 key = @""; // see key collector
526 else if ([key hasPrefix:@"body["]) {
529 key = [key substringFromIndex:5];
530 r = [key rangeOfString:@"]"];
532 key = [key substringToIndex:r.location];
534 [flatContents setObject:data forKey:key];
539 - (NSDictionary *)fetchPlainTextParts {
540 return [self fetchPlainTextParts:[self plainTextContentFetchKeys]];
543 /* convert parts to strings */
545 - (NSString *)stringForData:(NSData *)_data partInfo:(NSDictionary *)_info {
549 if (![_data isNotNull])
554 charset = [[_info valueForKey:@"parameterList"] valueForKey:@"charset"];
555 if ([charset isNotNull] && [charset length] > 0)
556 s = [NSString stringWithData:_data usingEncodingNamed:charset];
558 if (s == nil) { /* no charset provided, fall back to UTF-8 */
559 s = [[NSString alloc] initWithData:_data encoding:NSUTF8StringEncoding];
566 - (NSDictionary *)stringifyTextParts:(NSDictionary *)_datas {
567 NSMutableDictionary *md;
571 md = [NSMutableDictionary dictionaryWithCapacity:4];
572 keys = [_datas keyEnumerator];
573 while ((key = [keys nextObject]) != nil) {
577 info = [self lookupInfoForBodyPart:key];
578 if ((s = [self stringForData:[_datas objectForKey:key] partInfo:info]))
579 [md setObject:s forKey:key];
583 - (NSDictionary *)fetchPlainTextStrings:(NSArray *)_fetchKeys {
585 The fetched parts are NSData objects, this method converts them into
586 NSString objects based on the information inside the bodystructure.
588 The fetch-keys are body fetch-keys like: body[text] or body[1.2.3].
589 The keys in the result dictionary are "" for 'text' and 1.2.3 for parts.
593 if ((datas = [self fetchPlainTextParts:_fetchKeys]) == nil)
595 if ([datas isKindOfClass:[NSException class]])
598 return [self stringifyTextParts:datas];
603 - (NSException *)addFlags:(id)_flags {
604 return [[self imap4Connection] addFlags:_flags toURL:[self imap4URL]];
606 - (NSException *)removeFlags:(id)_flags {
607 return [[self imap4Connection] removeFlags:_flags toURL:[self imap4URL]];
612 - (BOOL)isDeletionAllowed {
613 return [[self container] isDeleteAndExpungeAllowed];
618 - (id)lookupImap4BodyPartKey:(NSString *)_key inContext:(id)_ctx {
619 // TODO: we might want to check for existence prior controller creation
622 clazz = [SOGoMailBodyPart bodyPartClassForKey:_key inContext:_ctx];
623 return [[[clazz alloc] initWithName:_key inContainer:self] autorelease];
626 - (id)lookupName:(NSString *)_key inContext:(id)_ctx acquire:(BOOL)_flag {
629 /* first check attributes directly bound to the application */
630 if ((obj = [super lookupName:_key inContext:_ctx acquire:NO]) != nil)
633 /* lookup body part */
635 if ([self isBodyPartKey:_key inContext:_ctx]) {
636 if ((obj = [self lookupImap4BodyPartKey:_key inContext:_ctx]) != nil) {
638 [self logWithFormat:@"mail looked up part %@: %@", _key, obj];
643 /* return 404 to stop acquisition */
644 return [NSException exceptionWithHTTPStatus:404 /* Not Found */
645 reason:@"Did not find mail method or part-reference!"];
650 - (BOOL)davIsCollection {
651 /* while a mail has child objects, it should appear as a file in WebDAV */
655 - (id)davContentLength {
656 return [[self fetchCoreInfos] valueForKey:@"size"];
659 - (NSDate *)davCreationDate {
660 // TODO: use INTERNALDATE once NGImap4 supports that
663 - (NSDate *)davLastModified {
664 return [self davCreationDate];
667 - (NSException *)davMoveToTargetObject:(id)_target newName:(NSString *)_name
670 [self logWithFormat:@"TODO: should move mail as '%@' to: %@",
672 return [NSException exceptionWithHTTPStatus:501 /* Not Implemented */
673 reason:@"not implemented"];
676 - (NSException *)davCopyToTargetObject:(id)_target newName:(NSString *)_name
680 Note: this is special because we create SOGoMailObject's even if they do
681 not exist (for performance reasons).
683 Also: we cannot really take a target resource, the ID will be assigned by
685 We even cannot return a 'location' header instead because IMAP4
686 doesn't tell us the new ID.
690 destImap4URL = ([_name length] == 0)
691 ? [[_target container] imap4URL]
692 : [_target imap4URL];
694 return [[self mailManager] copyMailURL:[self imap4URL]
695 toFolderURL:destImap4URL
696 password:[self imap4Password]];
701 - (id)GETAction:(id)_ctx {
706 if ((error = [self matchesRequestConditionInContext:_ctx]) != nil) {
707 /* check whether the mail still exists */
708 if (![self doesMailExist]) {
709 return [NSException exceptionWithHTTPStatus:404 /* Not Found */
710 reason:@"mail was deleted"];
712 return error; /* return 304 or 416 */
715 content = [self content];
716 if ([content isKindOfClass:[NSException class]])
718 if (content == nil) {
719 return [NSException exceptionWithHTTPStatus:404 /* Not Found */
720 reason:@"did not find IMAP4 message"];
724 [r setHeader:@"message/rfc822" forKey:@"content-type"];
725 [r setContent:content];
731 - (NSException *)trashInContext:(id)_ctx {
733 Trashing is three actions:
734 a) copy to trash folder
735 b) mark mail as deleted
738 In case b) or c) fails, we can't do anything because IMAP4 doesn't tell us
739 the ID used in the trash folder.
741 SOGoMailFolder *trashFolder;
744 // TODO: check for safe HTTP method
746 trashFolder = [[self mailAccountFolder] trashFolderInContext:_ctx];
747 if ([trashFolder isKindOfClass:[NSException class]])
748 return (NSException *)trashFolder;
749 if (![trashFolder isNotNull]) {
750 return [NSException exceptionWithHTTPStatus:500 /* Server Error */
751 reason:@"Did not find Trash folder!"];
753 [trashFolder flushMailCaches];
757 error = [self davCopyToTargetObject:trashFolder
758 newName:@"fakeNewUnusedByIMAP4" /* autoassigned */
760 if (error != nil) return error;
762 /* b) mark deleted */
764 error = [[self imap4Connection] markURLDeleted:[self imap4URL]];
765 if (error != nil) return error;
769 error = [[self imap4Connection] expungeAtURL:[[self container] imap4URL]];
770 if (error != nil) return error; // TODO: unflag as deleted?
771 [self flushMailCaches];
776 - (NSException *)delete {
778 Note: delete is different to DELETEAction: for mails! The 'delete' runs
779 either flags a message as deleted or moves it to the Trash while
780 the DELETEAction: really deletes a message (by flagging it as
781 deleted _AND_ performing an expunge).
783 // TODO: copy to Trash folder
786 // TODO: check for safe HTTP method
788 error = [[self imap4Connection] markURLDeleted:[self imap4URL]];
791 - (id)DELETEAction:(id)_ctx {
794 // TODO: ensure safe HTTP method
796 error = [[self imap4Connection] markURLDeleted:[self imap4URL]];
797 if (error != nil) return error;
799 error = [[self imap4Connection] expungeAtURL:[[self container] imap4URL]];
800 if (error != nil) return error; // TODO: unflag as deleted?
802 return [NSNumber numberWithBool:YES]; /* delete was successful */
805 /* some mail classification */
807 - (BOOL)isMailingListMail {
810 if ((h = [self mailHeaders]) == nil)
813 return [[h objectForKey:@"list-id"] isNotEmpty];
816 - (BOOL)isVirusScanned {
819 if ((h = [self mailHeaders]) == nil)
822 if (![[h objectForKey:@"x-virus-status"] isNotEmpty]) return NO;
823 if (![[h objectForKey:@"x-virus-scanned"] isNotEmpty]) return NO;
827 - (NSString *)scanListHeaderValue:(id)_value
828 forFieldWithPrefix:(NSString *)_prefix
830 /* Note: not very tolerant on embedded commands and <> */
831 // TODO: does not really belong here, should be a header-field-parser
834 if (![_value isNotEmpty])
837 if ([_value isKindOfClass:[NSArray class]]) {
841 e = [_value objectEnumerator];
842 while ((value = [e nextObject]) != nil) {
843 value = [self scanListHeaderValue:value forFieldWithPrefix:_prefix];
844 if (value != nil) return value;
849 if (![_value isKindOfClass:[NSString class]])
852 /* check for commas in string values */
853 r = [_value rangeOfString:@","];
855 return [self scanListHeaderValue:[_value componentsSeparatedByString:@","]
856 forFieldWithPrefix:_prefix];
859 /* value qualifies */
860 if (![(NSString *)_value hasPrefix:_prefix])
864 if ([_value characterAtIndex:0] == '<') {
865 r = [_value rangeOfString:@">"];
866 _value = (r.length == 0)
867 ? [_value substringFromIndex:1]
868 : [_value substringWithRange:NSMakeRange(1, r.location - 2)];
874 - (NSString *)mailingListArchiveURL {
875 return [self scanListHeaderValue:
876 [[self mailHeaders] objectForKey:@"list-archive"]
877 forFieldWithPrefix:@"<http://"];
879 - (NSString *)mailingListSubscribeURL {
880 return [self scanListHeaderValue:
881 [[self mailHeaders] objectForKey:@"list-subscribe"]
882 forFieldWithPrefix:@"<http://"];
884 - (NSString *)mailingListUnsubscribeURL {
885 return [self scanListHeaderValue:
886 [[self mailHeaders] objectForKey:@"list-unsubscribe"]
887 forFieldWithPrefix:@"<http://"];
894 Note: There is one thing which *can* change for an existing message,
895 those are the IMAP4 flags (and annotations, which we do not use).
896 Since we don't render the flags, it should be OK, if this changes
897 we must embed the flagging into the etag.
904 - (BOOL)isDebuggingEnabled {
908 @end /* SOGoMailObject */