Skip to content
This repository was archived by the owner on Jun 26, 2020. It is now read-only.

Commit f57b138

Browse files
KyleKingdweremeichik
authored andcommitted
[unsubscribe-plugin] merge n1-unsubscribe plugin into core (#49)
Moves the unsubscribe plugin into an internal package. * init: `unsubscribe` as internal package Integrate n1-unsubscribe into nylas-mail directly See: https://github.com/colinking/n1-unsubscribe * fix broken N1 api request and fix out-of-date “N1” references with “Nylas Mail”
1 parent d396d1b commit f57b138

37 files changed

+971
-2
lines changed

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,7 @@ Great starting points for creating your own plugins!
100100
- [View on GitHub](https://github.com/nylas/nylas-mail/tree/master/internal_packages/message-view-on-github)
101101
- [Personal Level Indicators](https://github.com/nylas/nylas-mail/tree/master/internal_packages/personal-level-indicators)
102102
- [Phishing Detection](https://github.com/nylas/nylas-mail/tree/master/internal_packages/phishing-detection)
103+
- [Unsubscribe](https://github.com/nylas/nylas-mail/tree/master/internal_packages/unsubscribe)
103104

104105
#### Community Plugins
105106

@@ -108,7 +109,6 @@ Note these are not tested or officially supported by Nylas, but we still think t
108109
- [Jiffy](http://noahbuscher.github.io/N1-Jiffy/)—Insert animated GIFs
109110
- [Weather](https://github.com/jackiehluo/n1-weather)
110111
- [Todoist](https://github.com/alexfruehwirth/N1TodoistIntegration)
111-
- [Unsubscribe](https://github.com/colinking/n1-unsubscribe)
112112
- [Squirt Speed Reader](https://github.com/HarleyKwyn/squirt-reader-N1-plugin/)
113113
- [Website Launcher](https://github.com/adriangrantdotorg/nylas-n1-background-webpage)—Opens a URL in separate window
114114
- In Development: [Cypher](https://github.com/mbilker/cypher) (PGP Encryption)
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
![Unsubscribe: unsubscribe without leaving Nylas Mail](plugin.png)
2+
3+
Quickly unsubscribe from emails without leaving Nylas Mail. The unsubscribe plugin parses the `list-unsubscribe` header and the email body to look for the best way to unsubscribe. If an unsubscribe email address can be found, the plugin will send one in the background on your behalf. If only a browser link is found, either a mini Nylas Mail browser window will open or you will be redirected to your default browser based on your preferences and certain exceptions.
4+
5+
## Keyboard Shortcuts
6+
7+
Press <kbd>CMD</kbd> + <kbd>ALT</kbd> + <kbd>U</kbd> when viewing an email. This shortcut can be changed in the preference panel (`Nylas->Preferences->Unsubscribe`).
8+
9+
## Reporting Bugs
10+
11+
- **Feature Requests or Bug Reports**: Submit them through the [issues](issues) pane.
12+
- **Mishandled Emails**: Find an email which this plugin doesn't handle correctly? Not finding an unsubscribe link when there should be one? Forward the email to us at <a href="mailto:[email protected]">[email protected]</a> and we'll look into it.
13+
14+
## Made by
15+
16+
[Kyle King](http://kyleking.me) and [Colin King](http://colinking.co)
Loading
Loading
Loading
Loading
Loading
Loading
Loading
Loading
Loading
Loading
Loading
Loading
Loading
Loading
Loading
Loading
Loading
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
{
2+
"unsubscribe:unsubscribe": "mod+alt+u"
3+
}
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
export default {
2+
LOADING: 'LOADING',
3+
ERRORED: 'ERRORED',
4+
READY: 'READY',
5+
UNSUBSCRIBED: 'UNSUBSCRIBED',
6+
};
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import {ComponentRegistry, PreferencesUIStore} from 'nylas-exports';
2+
import {ThreadUnsubscribeQuickActionButton, ThreadUnsubscribeToolbarButton}
3+
from './ui/unsubscribe-buttons';
4+
import UnsubscribePreferences from './ui/unsubscribe-preferences';
5+
6+
export const config = {
7+
defaultBrowser: {
8+
"title": "Default browser",
9+
"type": "string",
10+
"default": "popup",
11+
"enum": ["popup", "native"],
12+
'enumLabels': ["Popup Window", "Native Browser"],
13+
},
14+
handleThreads: {
15+
"title": "Default unsubscribe behaivor",
16+
"type": "string",
17+
"default": "archive",
18+
"enum": ["archive", "trash", "none"],
19+
'enumLabels': ["Archive", "Trash", "None"],
20+
},
21+
confirmForEmail: {
22+
"title": "Confirm before sending email-based unsubscribe requests",
23+
"type": "boolean",
24+
"default": false,
25+
},
26+
confirmForBrowser: {
27+
"title": "Confirm before opening web-based unsubscribe links",
28+
"type": "boolean",
29+
"default": false,
30+
},
31+
}
32+
33+
export function activate() {
34+
ComponentRegistry.register(ThreadUnsubscribeQuickActionButton,
35+
{ role: 'ThreadListQuickAction' });
36+
ComponentRegistry.register(ThreadUnsubscribeToolbarButton,
37+
{ role: 'ThreadActionsToolbarButton' });
38+
39+
this.preferencesTab = new PreferencesUIStore.TabItem({
40+
tabId: 'Unsubscribe',
41+
displayName: "Unsubscribe",
42+
component: UnsubscribePreferences,
43+
});
44+
PreferencesUIStore.registerPreferencesTab(this.preferencesTab);
45+
}
46+
47+
export function deactivate() {
48+
PreferencesUIStore.unregisterPreferencesTab(this.preferencesTab);
49+
ComponentRegistry.unregister(ThreadUnsubscribeQuickActionButton);
50+
ComponentRegistry.unregister(ThreadUnsubscribeToolbarButton);
51+
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import ThreadUnsubscribeStore from './thread-unsubscribe-store';
2+
3+
class ThreadUnsubscribeStoreManager {
4+
constructor() {
5+
this.threads = {};
6+
}
7+
8+
getStoreForThread(thread) {
9+
const id = thread.id;
10+
if (this.threads[id] === undefined) {
11+
this.threads[id] = new ThreadUnsubscribeStore(thread);
12+
}
13+
return this.threads[id];
14+
}
15+
}
16+
17+
export default new ThreadUnsubscribeStoreManager();
Lines changed: 212 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,212 @@
1+
import {
2+
Actions,
3+
TaskFactory,
4+
FocusedPerspectiveStore,
5+
NylasAPI,
6+
NylasAPIRequest,
7+
} from 'nylas-exports';
8+
import NylasStore from 'nylas-store';
9+
import {MailParser} from 'mailparser';
10+
import {remote} from 'electron';
11+
import open from 'open';
12+
import _ from 'underscore';
13+
import {logIfDebug, shortenURL, shortenEmail, interpretEmail, userConfirm} from './util/helpers';
14+
import {electronCantOpen} from './util/blacklist';
15+
import EmailParser from './util/email-parser';
16+
import ThreadConditionType from './enum/threadConditionType';
17+
18+
export default class ThreadUnsubscribeStore extends NylasStore {
19+
constructor(thread) {
20+
super();
21+
this.settings = NylasEnv.config.get("unsubscribe");
22+
23+
if (!thread) {
24+
NylasEnv.reportError(new Error("Invalid thread object"));
25+
this.threadState = {
26+
id: null,
27+
condition: ThreadConditionType.ERRORED,
28+
}
29+
} else {
30+
this.thread = thread;
31+
this.threadState = {
32+
id: this.thread.id,
33+
condition: ThreadConditionType.LOADING,
34+
}
35+
this.messages = this.thread.__messages;
36+
this._loadLinks();
37+
}
38+
}
39+
40+
_triggerUpdate() {
41+
this.trigger(this.threadState);
42+
}
43+
44+
unsubscribe() {
45+
if (this.parser && this.parser.canUnsubscribe()) {
46+
const unsubscribeHandler = (error, unsubscribed) => {
47+
if (error) {
48+
this.threadState.condition = ThreadConditionType.ERRORED;
49+
NylasEnv.reportError(error, this);
50+
} else if (unsubscribed) {
51+
this._moveThread();
52+
this.threadState.condition = ThreadConditionType.UNSUBSCRIBED;
53+
}
54+
this._triggerUpdate();
55+
};
56+
57+
if (this.parser.emails.length > 0) {
58+
this._unsubscribeViaMail(this.parser.emails[0], unsubscribeHandler);
59+
} else {
60+
this._unsubscribeViaBrowser(this.parser.urls[0], unsubscribeHandler);
61+
}
62+
}
63+
}
64+
65+
_loadLinks() {
66+
this._loadMessagesViaAPI((error, email) => {
67+
if (error) {
68+
this.threadState.condition = ThreadConditionType.ERRORED;
69+
NylasEnv.reportError(error, this);
70+
} else if (email) {
71+
const confirmText = "Are you sure that you want to unsubscribe?";
72+
this.isForwarded = this.thread.subject.match(/^Fwd: /i);
73+
this.confirmText = this.isForwarded ? `The email was forwarded. ${confirmText}` : confirmText;
74+
75+
this.parser = new EmailParser(email.headers, email.html, email.text);
76+
this.threadState.condition = this.parser.canUnsubscribe() ? ThreadConditionType.READY : ThreadConditionType.DISABLED;
77+
// Output troubleshooting info
78+
logIfDebug(`Found ${(this.parser.canUnsubscribe() ? "" : "no ")}links for: "${this.thread.subject}"`);
79+
logIfDebug(this.parser);
80+
} else {
81+
this.threadState.condition = ThreadConditionType.DISABLED;
82+
}
83+
this._triggerUpdate();
84+
});
85+
}
86+
87+
_loadMessagesViaAPI(callback) {
88+
if (this.messages && this.messages.length > 0) {
89+
if (this.messages[0].draft || (this.messages[0].categories &&
90+
_.some(this.messages[0].categories, _.matcher({displayName: "Sent Mail"})))) {
91+
// Can't unsubscribe from draft or sent emails
92+
callback(null, null);
93+
} else {
94+
// Fetch the email contents to parse for unsubscribe links
95+
// NOTE: This will only make a request for the first email message in the thread,
96+
// instead of all messages based on the assumption that the first email will have
97+
// an unsubscribe link iff you can unsubscribe from that thread.
98+
const messagePath = `/messages/${this.messages[0].id}`;
99+
new NylasAPIRequest({
100+
api: NylasAPI,
101+
options: {
102+
accountId: this.thread.accountId,
103+
path: messagePath,
104+
headers: {Accept: "message/rfc822"},
105+
json: false,
106+
},
107+
})
108+
.run()
109+
.then((rawEmail) => {
110+
const mailparser = new MailParser();
111+
mailparser.on('end', (parsedEmail) => {
112+
callback(null, parsedEmail);
113+
});
114+
mailparser.write(rawEmail);
115+
mailparser.end();
116+
})
117+
.catch((err) => {
118+
callback(err)
119+
});
120+
}
121+
} else {
122+
callback(new Error('No messages found to parse for unsubscribe links.'));
123+
}
124+
}
125+
126+
_unsubscribeViaBrowser(url, callback) {
127+
if ((!this.isForwarded && !this.settings.confirmForBrowser) ||
128+
userConfirm(this.confirmText, `A browser will be opened at: ${shortenURL(url)}`)) {
129+
logIfDebug(`Opening a browser window to:\n${url}`);
130+
if (this.settings.defaultBrowser === "native" || electronCantOpen(url)) {
131+
open(url);
132+
callback(null, /* unsubscribed=*/true);
133+
} else {
134+
const browserWindow = new remote.BrowserWindow({
135+
'web-preferences': { 'web-security': false, 'nodeIntegration': false },
136+
'width': 1000,
137+
'height': 800,
138+
'center': true,
139+
"alwaysOnTop": true,
140+
});
141+
browserWindow.on('closed', () => {
142+
callback(null, /* unsubscribed=*/true);
143+
});
144+
browserWindow.webContents.on('did-fail-load', (event, errorCode, errorDescription) => {
145+
// Unable to load this URL in a browser window. Redirect to a native browser.
146+
logIfDebug(`Failed to open URL in browser window: ${errorCode} ${errorDescription}`);
147+
browserWindow.destroy();
148+
open(url);
149+
});
150+
browserWindow.loadURL(url);
151+
browserWindow.show();
152+
}
153+
} else {
154+
callback(null, /* unsubscribed=*/false);
155+
}
156+
}
157+
158+
_unsubscribeViaMail(emailAddress, callback) {
159+
if (emailAddress) {
160+
if ((!this.isForwarded && !this.settings.confirmForEmail) ||
161+
userConfirm(this.confirmText, `An email will be sent to:\n${shortenEmail(emailAddress)}`)) {
162+
logIfDebug(`Sending an email to: ${emailAddress}`);
163+
new NylasAPIRequest({
164+
api: NylasAPI,
165+
options: {
166+
accountId: this.thread.accountId,
167+
path: '/send',
168+
method: 'POST',
169+
body: interpretEmail(emailAddress),
170+
},
171+
})
172+
.run()
173+
.catch((err) => {
174+
NylasEnv.reportError(err, this)
175+
});
176+
// Send the callback now so that emails are moved immediately
177+
// instead of waiting for the email to be sent.
178+
callback(null, /* unsubscribed= */true);
179+
} else {
180+
callback(null, /* unsubscribed= */false);
181+
}
182+
} else {
183+
callback(new Error(`Invalid email address (${emailAddress})`), /* unsubscribed= */false);
184+
}
185+
}
186+
187+
_moveThread() {
188+
switch (this.settings.handleThreads) {
189+
case "trash":
190+
if (FocusedPerspectiveStore.current().canTrashThreads([this.thread])) {
191+
const tasks = TaskFactory.tasksForMovingToTrash({
192+
threads: [this.thread],
193+
fromPerspective: FocusedPerspectiveStore.current(),
194+
});
195+
Actions.queueTasks(tasks);
196+
}
197+
break;
198+
case "archive":
199+
if (FocusedPerspectiveStore.current().canArchiveThreads([this.thread])) {
200+
const tasks = TaskFactory.tasksForArchiving({
201+
threads: [this.thread],
202+
fromPerspective: FocusedPerspectiveStore.current(),
203+
});
204+
Actions.queueTasks(tasks);
205+
}
206+
break;
207+
default:
208+
// "none" case -- do not move email
209+
}
210+
Actions.popSheet();
211+
}
212+
}

0 commit comments

Comments
 (0)