Don't display Growl notifications for emails which are already marked as read when they are received.
This is a huge usability improvement for my use case and I imagine for that of many others, as I read emails on my phone and other computers throughout the day and don't like my screen flooded with useless notifications when I return and check email.
2 Copyright (c) The Growl Project, 2004-2005
5 Redistribution and use in source and binary forms, with or without modification,
6 are permitted provided that the following conditions are met:
8 1. Redistributions of source code must retain the above copyright
9 notice, this list of conditions and the following disclaimer.
10 2. Redistributions in binary form must reproduce the above copyright
11 notice, this list of conditions and the following disclaimer in the
12 documentation and/or other materials provided with the distribution.
13 3. Neither the name of Growl nor the names of its contributors
14 may be used to endorse or promote products derived from this software
15 without specific prior written permission.
17 THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
18 ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
19 WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED.
20 IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT,
21 INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING,
22 BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
23 DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
24 LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE
25 OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED
26 OF THE POSSIBILITY OF SUCH DAMAGE.
32 // Created by Adam Iser on Mon Jul 26 2004.
33 // Copyright (c) 2004-2005 The Growl Project. All rights reserved.
37 #import "Message+GrowlMail.h"
39 #import "MailHeaders.h"
40 #import "MessageFrameworkHeaders.h"
41 #import <objc/objc-class.h>
49 #define AUTO_THRESHOLD 10
51 #define MAX_NOTIFICATION_THREADS 5
53 static int activeNotificationThreads = 0;
55 //#define GROWL_MAIL_DEBUG
57 NSBundle *GMGetGrowlMailBundle(void) {
58 return [NSBundle bundleForClass:[GrowlMail class]];
61 @implementation GrowlMail
63 static int messageCopies = 0;
65 #pragma mark Panic buttons
67 //The purpose of this method is to shut down GrowlMail completely: we should not be notified of any messages, nor notify the user of any messages, after this message is called.
68 - (void) shutDownGrowlMail {
69 [[NSNotificationCenter defaultCenter] removeObserver:self];
70 [GrowlApplicationBridge setGrowlDelegate:nil];
73 //This is a suicide pill. GrowlMail sends itself this message any time it detects a change in Mail's implementation, such as a missing method or an object of the wrong class.
74 - (void) shutDownGrowlMailAndWarn:(NSString *)specificWarning {
75 NSLog(NSLocalizedString(@"WARNING: Mail is not behaving in the way that GrowlMail expects. This is probably because GrowlMail is incompatible with the version of Mail you're using. GrowlMail will now turn itself off. Please check the Growl website for a new version. If you're a programmer and want to debug this error, run gdb, load Mail, set a breakpoint on %s, and run.", /*comment*/ nil), __PRETTY_FUNCTION__);
77 NSLog(@"Furthermore, the caller provided a more specific message: %@", specificWarning);
79 [self shutDownGrowlMail];
82 #pragma mark Boring bookkeeping stuff
87 // this image is leaked
88 NSImage *image = [[NSImage alloc] initByReferencingFile:[GMGetGrowlMailBundle() pathForImageResource:@"GrowlMail"]];
89 [image setName:@"GrowlMail"];
91 [GrowlMail registerBundle];
93 NSNumber *automatic = [NSNumber numberWithInt:MODE_AUTO];
94 NSDictionary *defaultsDictionary = [[NSDictionary alloc] initWithObjectsAndKeys:
95 @"(%account) %sender", @"GMTitleFormat",
96 @"%subject\n%body", @"GMDescriptionFormat",
97 automatic, @"GMSummaryMode",
98 [NSNumber numberWithBool:YES], @"GMEnableGrowlMailBundle",
99 [NSNumber numberWithBool:NO], @"GMInboxOnly",
101 [[NSUserDefaults standardUserDefaults] registerDefaults:defaultsDictionary];
102 [defaultsDictionary release];
104 NSLog(@"Loaded GrowlMail %@", [GMGetGrowlMailBundle() objectForInfoDictionaryKey:(NSString *)kCFBundleVersionKey]);
107 + (BOOL) hasPreferencesPanel {
111 + (NSString *) preferencesOwnerClassName {
112 return @"GrowlMailPreferencesModule";
115 + (NSString *) preferencesPanelName {
120 if ((self = [super init])) {
121 NSString *privateFrameworksPath = [GMGetGrowlMailBundle() privateFrameworksPath];
122 NSString *growlBundlePath = [privateFrameworksPath stringByAppendingPathComponent:@"Growl.framework"];
124 NSBundle *growlBundle = [NSBundle bundleWithPath:growlBundlePath];
126 if ([growlBundle load]) {
127 // Register ourselves as a Growl delegate
128 [GrowlApplicationBridge setGrowlDelegate:self];
130 if ([GrowlApplicationBridge respondsToSelector:@selector(frameworkInfoDictionary)]) {
131 NSDictionary *infoDictionary = [GrowlApplicationBridge frameworkInfoDictionary];
132 NSLog(@"Using Growl.framework %@ (%@)",
133 [infoDictionary objectForKey:@"CFBundleShortVersionString"],
134 [infoDictionary objectForKey:(NSString *)kCFBundleVersionKey]);
136 NSLog(@"Using a version of Growl.framework older than 1.1. One of the other installed Mail plugins should be updated to Growl.framework 1.1 or later.");
140 [[NSNotificationCenter defaultCenter] addObserver:self
141 selector:@selector(messageStoreDidAddMessages:)
142 name:@"MessageStoreMessagesAdded_inMainThread_"
144 [[NSNotificationCenter defaultCenter] addObserver:self
145 selector:@selector(monitoredActivityStarted:)
146 name:@"MonitoredActivityStarted_inMainThread_"
148 [[NSNotificationCenter defaultCenter] addObserver:self
149 selector:@selector(monitoredActivityEnded:)
150 name:@"MonitoredActivityEnded_inMainThread_"
153 #ifdef GROWL_MAIL_DEBUG
155 [[NSNotificationCenter defaultCenter] addObserver:self
156 selector:@selector(showAllNotifications:)
157 name:nil object:nil];
162 NSLog(@"Could not load Growl.framework, GrowlMail disabled");
169 - (void)showAllNotifications:(NSNotification *)notification
171 if (([[notification name] rangeOfString:@"NSWindow"].location == NSNotFound) &&
172 ([[notification name] rangeOfString:@"NSMouse"].location == NSNotFound) &&
173 ([[notification name] rangeOfString:@"_NSThread"].location == NSNotFound)) {
174 NSLog(@"%@", notification);
178 - (void)monitoredActivityStarted:(NSNotification *)notification
180 if ([[[notification object] description] isEqualToString:@"Copying messages"]) {
182 #ifdef GROWL_MAIL_DEBUG
183 NSLog(@"Copying a message: messageCopies is now %i", messageCopies);
185 if (messageCopies <= 0)
186 [self shutDownGrowlMailAndWarn:@"Number of message-copying operations overflowed. How on earth did you accomplish starting more than 2 billion copying operations at a time?!"];
190 - (void)monitoredActivityEnded:(NSNotification *)notification
192 if ([[[notification object] description] isEqualToString:@"Copying messages"]) {
193 if (messageCopies <= 0)
194 [self shutDownGrowlMailAndWarn:@"Number of message-copying operations went below 0. It is not possible to have a negative number of copying operations!"];
196 #ifdef GROWL_MAIL_DEBUG
197 NSLog(@"Finished copying a message: messageCopies is now %i", messageCopies);
203 [self shutDownGrowlMail];
204 [[NSNotificationCenter defaultCenter] removeObserver:self];
209 #pragma mark GrowlApplicationBridge delegate methods
211 - (NSString *) applicationNameForGrowl {
215 - (NSImage *) applicationIconForGrowl {
216 return [NSImage imageNamed:@"NSApplicationIcon"];
219 - (void) growlNotificationWasClicked:(NSString *)clickContext {
220 if ([clickContext length]) {
221 //Make sure we have all the methods we need.
222 if (!class_getClassMethod([Library class], @selector(messageWithMessageID:)))
223 [self shutDownGrowlMailAndWarn:@"Library does not respond to +messageWithMessageID:"];
224 if (!class_getInstanceMethod([SingleMessageViewer class], @selector(initForViewingMessage:showAllHeaders:viewingState:fromDefaults:)))
225 [self shutDownGrowlMailAndWarn:@"SingleMessageViewer does not respond to -initForViewingMessage:showAllHeaders:viewingState:fromDefaults:"];
226 if (!class_getInstanceMethod([SingleMessageViewer class], @selector(showAndMakeKey:)))
227 [self shutDownGrowlMailAndWarn:@"SingleMessageViewer does not respond to -showAndMakeKey:"];
229 Message *message = [Library messageWithMessageID:clickContext];
230 MessageViewingState *viewingState = [[MessageViewingState alloc] init];
231 SingleMessageViewer *messageViewer = [[SingleMessageViewer alloc] initForViewingMessage:message showAllHeaders:NO viewingState:viewingState fromDefaults:NO];
232 [viewingState release];
233 [messageViewer showAndMakeKey:YES];
234 [messageViewer release];
235 [Library markMessageAsViewed:message];
237 [NSApp activateIgnoringOtherApps:YES];
240 - (NSDictionary *) registrationDictionaryForGrowl {
241 // Register our ticket with Growl
242 NSArray *allowedNotifications = [NSArray arrayWithObjects:
243 NEW_MAIL_NOTIFICATION,
244 NEW_JUNK_MAIL_NOTIFICATION,
245 NEW_NOTE_NOTIFICATION,
247 NSDictionary *humanReadableNames = [NSDictionary dictionaryWithObjectsAndKeys:
248 NSLocalizedStringFromTableInBundle(@"New mail", nil, GMGetGrowlMailBundle(), ""), NEW_MAIL_NOTIFICATION,
249 NSLocalizedStringFromTableInBundle(@"New junk mail", nil, GMGetGrowlMailBundle(), ""), NEW_JUNK_MAIL_NOTIFICATION,
250 NSLocalizedStringFromTableInBundle(@"New note", nil, GMGetGrowlMailBundle(), ""), NEW_NOTE_NOTIFICATION,
252 NSArray *defaultNotifications = [NSArray arrayWithObject:NEW_MAIL_NOTIFICATION];
254 NSDictionary *ticket = [NSDictionary dictionaryWithObjectsAndKeys:
255 allowedNotifications, GROWL_NOTIFICATIONS_ALL,
256 defaultNotifications, GROWL_NOTIFICATIONS_DEFAULT,
257 humanReadableNames, GROWL_NOTIFICATIONS_HUMAN_READABLE_NAMES,
259 #ifdef GROWL_MAIL_DEBUG
260 NSLog(@"%s: Returning Growl dictionary %@", __PRETTY_FUNCTION__, ticket);
266 #pragma mark Mail notification handlers
268 + (void)showNotificationForMessage:(Message *)message
270 if (activeNotificationThreads < MAX_NOTIFICATION_THREADS) {
271 activeNotificationThreads++;
275 * If we want the message body, it may not be immediately available.
276 * It can be retrieved without blocking if it's available, which we initially try.
277 * However, if we really, really want it, we may have to request it in a blocking fashion:
278 * for example, if the user doesn't read the message and doesn't have Mail set to download it automatically,
279 * we'll never get it without blocking.
281 * Blocking the main thread is, of course, out of the question.
283 * We're making some assumptions about Mail's internals, but the fact that notifications are posted on auxiliary threads
284 * and then again with a _inMainThread_ suffix on the main thread indicates that threads are being used for mail access elsewhere.
286 [NSThread detachNewThreadSelector:@selector(showNotification)
290 [self performSelector:@selector(showNotificationForMessage:)
296 + (void)didFinishNotificationForMessage:(Message *)message
298 #pragma unused(message)
299 activeNotificationThreads--;
302 - (void)messageStoreDidAddMessages:(NSNotification *)notification {
303 if (!GMIsEnabled()) return;
305 #ifdef GROWL_MAIL_DEBUG
306 NSLog(@"%s called", __PRETTY_FUNCTION__);
310 #ifdef GROWL_MAIL_DEBUG
311 NSLog(@"Ignoring because %i message copies are in process", messageCopies);
316 Library *store = [notification object];
318 [self shutDownGrowlMailAndWarn:[NSString stringWithFormat:@"'%@' notification has no object", [notification name]]];
320 if ([store isKindOfClass:[LibraryStore class]]) {
321 //As of Tiger, this is normal; this notification is posted a couple times (perhaps once per inbox) with a LibraryStore object.
322 //This is not the notification we're looking for; we don't need to see its papers. We will move along now.
325 //We don't actually use the store. We only retrieve it and examine it at all because we know we don't want the one with a LibraryStore as its object.
326 //The rest of the handler should be able to work just fine without proving anything else about the store, since it doesn't use the store.
328 NSDictionary *userInfo = [notification userInfo];
329 if (!userInfo) [self shutDownGrowlMailAndWarn:@"Notification had no userInfo"];
331 NSArray *mailboxes = [userInfo objectForKey:@"mailboxes"];
332 #ifdef GROWL_MAIL_DEBUG
333 NSLog(@"%s: Adding messages to mailboxes %@", __PRETTY_FUNCTION__, mailboxes);
336 //As of Tiger, it's normal for about half of these notifications to not have any mailboxes. We simply ignore the notification in this case.
337 if (!(mailboxes && [mailboxes count])) return;
339 //Ignore a notification if we're ignoring all of the mailboxes involved.
340 Class MailAccount_class = [MailAccount class];
341 if (!class_getClassMethod(MailAccount_class, @selector(draftMailboxUids)))
342 [self shutDownGrowlMailAndWarn:@"MailAccount does not respond to +draftMailboxUids"];
343 if (!class_getClassMethod(MailAccount_class, @selector(outboxMailboxUids)))
344 [self shutDownGrowlMailAndWarn:@"MailAccount does not respond to +outboxMailboxUids"];
345 if (!class_getClassMethod(MailAccount_class, @selector(sentMessagesMailboxUids)))
346 [self shutDownGrowlMailAndWarn:@"MailAccount does not respond to +sentMessagesMailboxUids"];
347 if (!class_getClassMethod(MailAccount_class, @selector(trashMailboxUids)))
348 [self shutDownGrowlMailAndWarn:@"MailAccount does not respond to +trashMailboxUids"];
349 //We need this method to support the Inbox Only preference.
350 if (!class_getClassMethod(MailAccount_class, @selector(inboxMailboxUids)))
351 [self shutDownGrowlMailAndWarn:@"MailAccount does not respond to +inboxMailboxUids"];
353 //Ignore messages being written.
354 NSMutableSet *mailboxesToIgnore = [NSMutableSet setWithArray:[MailAccount draftMailboxUids]];
355 //Ignore messages being sent.
356 [mailboxesToIgnore unionSet:[NSSet setWithArray:[MailAccount outboxMailboxUids]]];
357 [mailboxesToIgnore unionSet:[NSSet setWithArray:[MailAccount sentMessagesMailboxUids]]];
358 //Ignore messages being deleted.
359 [mailboxesToIgnore unionSet:[NSSet setWithArray:[MailAccount trashMailboxUids]]];
361 NSSet *mailboxesSet = [NSSet setWithArray:mailboxes];
362 NSMutableSet *mailboxesNotIgnored = [[mailboxesSet mutableCopy] autorelease];
363 [mailboxesNotIgnored minusSet:mailboxesToIgnore];
364 if ([mailboxesNotIgnored count] == 0U)
367 NSArray *messages = [userInfo objectForKey:@"messages"];
368 if (!messages) [self shutDownGrowlMailAndWarn:@"Notification's userInfo has no messages"];
370 #ifdef GROWL_MAIL_DEBUG
371 NSLog(@"%s: Mail added messages [1] to mailboxes [2].\n[1]: %@\n[2]: %@", __PRETTY_FUNCTION__, messages, mailboxes);
374 unsigned count = [messages count];
376 int summaryMode = GMSummaryMode();
377 if (summaryMode == MODE_AUTO) {
378 if (count >= AUTO_THRESHOLD)
379 summaryMode = MODE_SUMMARY;
381 summaryMode = MODE_SINGLE;
384 #ifdef GROWL_MAIL_DEBUG
385 NSLog(@"Got %i new messages. Summary mode was %i and is now %i", count, GMSummaryMode(), summaryMode);
388 Class Message_class = [Message class];
390 switch (summaryMode) {
393 NSEnumerator *messagesEnum = [messages objectEnumerator];
395 while ((message = [messagesEnum nextObject])) {
396 MailboxUid *mailbox = [message mailbox];
397 //If this mailbox is not an inbox, and we only care about inboxes, then skip this message.
398 if (GMInboxOnly() && ![[MailAccount inboxMailboxUids] containsObject:mailbox])
401 MailAccount *account = [mailbox account];
402 if (![self isAccountEnabled:account])
405 if (![message isKindOfClass:Message_class])
406 [self shutDownGrowlMailAndWarn:[NSString stringWithFormat:@"Message in notification was not a Message; it is %@", message]];
408 if (![message respondsToSelector:@selector(isRead)] || ![message isRead]) {
409 /* Don't display read messages */
410 [[self class] showNotificationForMessage:message];
416 if (!class_getClassMethod([MailAccount class], @selector(mailAccounts)))
417 [self shutDownGrowlMailAndWarn:@"MailAccount does not respond to +mailAccounts"];
418 if (!class_getInstanceMethod(Message_class, @selector(mailbox)))
419 [self shutDownGrowlMailAndWarn:@"Message does not respond to -mailbox"];
421 NSArray *accounts = [MailAccount mailAccounts];
422 unsigned accountsCount = [accounts count];
423 NSCountedSet *accountSummary = [NSCountedSet setWithCapacity:accountsCount];
424 NSCountedSet *accountJunkSummary = [NSCountedSet setWithCapacity:accountsCount];
425 NSEnumerator *messagesEnum = [messages objectEnumerator];
426 NSArray *junkMailboxUids = [MailAccount junkMailboxUids];
428 while ((message = [messagesEnum nextObject])) {
429 MailboxUid *mailbox = [message mailbox];
430 //If this mailbox is not an inbox, and we only care about inboxes, then skip this message.
431 if (GMInboxOnly() && ![[MailAccount inboxMailboxUids] containsObject:mailbox])
434 MailAccount *account = [mailbox account];
435 if (![self isAccountEnabled:account])
438 if (([message isJunk]) || [junkMailboxUids containsObject:[message mailbox]])
439 [accountJunkSummary addObject:account];
441 [accountSummary addObject:account];
443 NSString *title = NSLocalizedStringFromTableInBundle(@"New mail", NULL, GMGetGrowlMailBundle(), "");
444 NSString *titleJunk = NSLocalizedStringFromTableInBundle(@"New junk mail", NULL, GMGetGrowlMailBundle(), "");
445 NSString *description;
447 MailAccount *account;
449 NSEnumerator *accountSummaryEnum = [accountSummary objectEnumerator];
450 while ((account = [accountSummaryEnum nextObject])) {
451 if (![self isAccountEnabled:account])
454 unsigned summaryCount = [accountSummary countForObject:account];
456 if (summaryCount == 1) {
457 description = [NSString stringWithFormat:NSLocalizedStringFromTableInBundle(@"%@ \n 1 new mail", NULL, GMGetGrowlMailBundle(), "%@ is an account name"), [account displayName]];
459 description = [NSString stringWithFormat:NSLocalizedStringFromTableInBundle(@"%@ \n %u new mails", NULL, GMGetGrowlMailBundle(), "%@ is an account name; %u becomes a number"), [account displayName], summaryCount];
461 [GrowlApplicationBridge notifyWithTitle:title
462 description:description
463 notificationName:NEW_MAIL_NOTIFICATION
467 clickContext:@""]; // non-nil click context
471 NSEnumerator *accountJunkSummaryEnum = [accountJunkSummary objectEnumerator];
472 while ((account = [accountJunkSummaryEnum nextObject])) {
473 if (![self isAccountEnabled:account])
476 unsigned summaryCount = [accountJunkSummary countForObject:account];
478 if (summaryCount == 1) {
479 description = [NSString stringWithFormat:NSLocalizedStringFromTableInBundle(@"%@ \n 1 new mail", NULL, GMGetGrowlMailBundle(), "%@ is an account name"), [account displayName]];
481 description = [NSString stringWithFormat:NSLocalizedStringFromTableInBundle(@"%@ \n %u new mails", NULL, GMGetGrowlMailBundle(), "%@ is an account name; %u becomes a number"), [account displayName], summaryCount];
483 [GrowlApplicationBridge notifyWithTitle:titleJunk
484 description:description
485 notificationName:NEW_JUNK_MAIL_NOTIFICATION
489 clickContext:@""]; // non-nil click context
497 #pragma mark Preferences
499 - (BOOL) isAccountEnabled:(MailAccount *)account {
500 BOOL isEnabled = YES;
501 NSDictionary *accountSettings = [[NSUserDefaults standardUserDefaults] objectForKey:@"GMAccounts"];
502 if (accountSettings) {
503 NSNumber *value = [accountSettings objectForKey:[account path]];
505 isEnabled = [value boolValue];
510 - (void) setAccount:(MailAccount *)account enabled:(BOOL)yesOrNo {
511 NSDictionary *accountSettings = [[NSUserDefaults standardUserDefaults] objectForKey:@"GMAccounts"];
512 NSMutableDictionary *newSettings = [[accountSettings mutableCopy] autorelease];
514 newSettings = [NSMutableDictionary dictionaryWithCapacity:1U];
515 [newSettings setObject:[NSNumber numberWithBool:yesOrNo] forKey:[account path]];
516 [[NSUserDefaults standardUserDefaults] setObject:newSettings forKey:@"GMAccounts"];
521 BOOL GMIsEnabled(void) {
522 NSNumber *enabledNum = [[NSUserDefaults standardUserDefaults] objectForKey:@"GMEnableGrowlMailBundle"];
523 return enabledNum ? [enabledNum boolValue] : YES;
526 int GMSummaryMode(void) {
527 NSNumber *summaryModeNum = [[NSUserDefaults standardUserDefaults] objectForKey:@"GMSummaryMode"];
528 return summaryModeNum ? [summaryModeNum intValue] : MODE_AUTO;
531 BOOL GMInboxOnly(void) {
532 NSNumber *inboxOnlyNum = [[NSUserDefaults standardUserDefaults] objectForKey:@"GMInboxOnly"];
533 return inboxOnlyNum ? [inboxOnlyNum boolValue] : YES;
536 NSString *GMTitleFormatString(void) {
537 NSString *titleFormat = [[NSUserDefaults standardUserDefaults] stringForKey:@"GMTitleFormat"];
538 return titleFormat ? titleFormat : @"(%account) %sender";
541 NSString *GMDescriptionFormatString(void) {
542 NSString *descriptionFormat = [[NSUserDefaults standardUserDefaults] stringForKey:@"GMDescriptionFormat"];
543 return descriptionFormat ? descriptionFormat : @"%subject\n%body";