Download messages at a folder level

A few changes:
- Right clicking on a folder (excluding Drafts, Unread, Starred) gives an option to "Export the %@ folder"
- In order to get this to work I needed to add the property/reference to the existing DB column `remoteFolderId`
- Sanitise regex utilities for paths
- Downloading a single email now has it's name sanitised to stop saving errors (i.e slashes, colons, etc.)
- Prettier/lint changes (for best practices)

**Issues:**
- Needs translations
- long running process ties up mailsync - need to deprioritise tasks or have a `Action.queueBackgroundTask` option
This commit is contained in:
Glenn 2022-08-25 12:49:49 +01:00
parent aba6709e30
commit 1d6dd27dbd
8 changed files with 101 additions and 13 deletions

View file

@ -11,14 +11,22 @@ import {
Actions,
RegExpUtils,
localized,
MessageStore,
GetMessageRFC2822Task,
MutableQuerySubscription,
Thread,
Message,
DatabaseStore,
Folder,
} from 'mailspring-exports';
import * as SidebarActions from './sidebar-actions';
import { ISidebarItem } from './types';
import { dialog } from '@electron/remote';
const idForCategories = categories => _.pluck(categories, 'id').join('-');
const countForItem = function (perspective) {
const countForItem = function(perspective) {
const unreadCountEnabled = AppEnv.config.get('core.workspace.showUnreadForAllCategories');
if (perspective.isInbox() || unreadCountEnabled) {
return perspective.unreadCount();
@ -28,7 +36,7 @@ const countForItem = function (perspective) {
const isItemSelected = perspective => FocusedPerspectiveStore.current().isEqual(perspective);
const isItemCollapsed = function (id) {
const isItemCollapsed = function(id) {
if (AppEnv.savedState.sidebarKeysCollapsed[id] !== undefined) {
return AppEnv.savedState.sidebarKeysCollapsed[id];
} else {
@ -36,14 +44,14 @@ const isItemCollapsed = function (id) {
}
};
const toggleItemCollapsed = function (item) {
const toggleItemCollapsed = function(item) {
if (!(item.children.length > 0)) {
return;
}
SidebarActions.setKeyCollapsed(item.id, !isItemCollapsed(item.id));
};
const onDeleteItem = function (item) {
const onDeleteItem = function(item) {
if (item.deleted === true) {
return;
}
@ -74,7 +82,40 @@ const onDeleteItem = function (item) {
);
};
const onEditItem = function (item, value) {
const onExportItem = async function(item) {
const filepath = await dialog.showOpenDialog({
properties: ['openDirectory', 'createDirectory'],
});
if (!filepath.canceled && filepath.filePaths[0]) {
await DatabaseStore.findAll<Message>(Message, { remoteFolderId: this.id }).then(messages => {
const tasks = [];
messages.forEach(message => {
const savePath = `${filepath.filePaths[0]}/${
message.subject
} - ${message.date.toLocaleString('sv-SE')} - ${message.id.substring(0, 10)}.eml`
.replace(/:/g, ';')
.replace(RegExpUtils.illegalPathCharacters(), '-')
.replace(RegExpUtils.unicodeControlCharacters(), '-');
tasks.push(
new GetMessageRFC2822Task({
messageId: message.id,
accountId: message.accountId,
filepath: savePath,
})
);
});
if (tasks.length > 0) {
Actions.queueTasks(tasks);
}
});
}
};
const onEditItem = function(item, value) {
let newDisplayName;
if (!value) {
return;
@ -134,6 +175,7 @@ export default class SidebarItem {
counterStyle,
onDelete: opts.deletable ? onDeleteItem : undefined,
onEdited: opts.editable ? onEditItem : undefined,
onExport: opts.exportable ? onExportItem : undefined,
onCollapseToggled: toggleItemCollapsed,
onDrop(item, event) {
@ -190,6 +232,9 @@ export default class SidebarItem {
if (opts.editable == null) {
opts.editable = true;
}
opts.exportable = true;
opts.contextMenuLabel = contextMenuLabel;
return this.forPerspective(id, perspective, opts);
}

View file

@ -20,6 +20,7 @@ export interface ISidebarItem {
deletable?: boolean;
editable?: boolean;
exportable?: boolean;
}
export interface ISidebarSection {

View file

@ -9,6 +9,7 @@ import {
GetMessageRFC2822Task,
Thread,
Message,
RegExpUtils,
} from 'mailspring-exports';
import { RetinaImg, ButtonDropdown, Menu } from 'mailspring-component-kit';
import { ipcRenderer, SaveDialogReturnValue } from 'electron';
@ -162,10 +163,13 @@ export default class MessageControls extends React.Component<MessageControlsProp
const { message } = this.props;
const filepath = await dialog.showSaveDialog({
filters: [{ name: 'Email', extensions: ['.eml'] }],
defaultPath: `${message.subject} - ${new Date().toLocaleDateString(
filters: [{ name: 'Email', extensions: ['eml'] }],
defaultPath: `${message.subject} - ${message.date.toLocaleString(
'sv-SE'
)} - ${message.id.substring(0, 10)}.eml`,
)} - ${message.id.substring(0, 10)}.eml`
.replace(/:/g, ';')
.replace(RegExpUtils.illegalPathCharacters(), '-')
.replace(RegExpUtils.unicodeControlCharacters(), '-'),
});
if (!filepath.canceled && filepath.filePath) {

View file

@ -223,6 +223,7 @@
"Explore Mailspring Pro": "Explore Mailspring Pro",
"Export Failed": "Export Failed",
"Export Raw Data": "Export Raw Data",
"Export the %@ folder": "Export the %@ folder",
"Facebook URL": "Facebook URL",
"Failed to load \"%@\"": "Failed to load \"%@\"",
"Failed to load config.json: %@": "Failed to load config.json: %@",

View file

@ -191,7 +191,11 @@ class OutlineViewItem extends Component<OutlineViewItemProps, OutlineViewItemSta
};
_shouldShowContextMenu = () => {
return this.props.item.onDelete != null || this.props.item.onEdited != null;
return (
this.props.item.onDelete != null ||
this.props.item.onEdited != null ||
this.props.item.onExport != null
);
};
_shouldAcceptDrop = event => {
@ -244,6 +248,10 @@ class OutlineViewItem extends Component<OutlineViewItemProps, OutlineViewItemSta
}
};
_onExport = () => {
this._runCallback('onExport');
};
_onInputFocus = event => {
const input = event.target;
input.selectionStart = input.selectionEnd = input.value.length;
@ -273,7 +281,7 @@ class OutlineViewItem extends Component<OutlineViewItemProps, OutlineViewItemSta
if (this.props.item.onEdited) {
menu.append(
new MenuItem({
label: `${localized(`Rename`)} ${contextMenuLabel}`,
label: `${localized('Rename')} ${contextMenuLabel}`,
click: this._onEdit,
})
);
@ -282,11 +290,19 @@ class OutlineViewItem extends Component<OutlineViewItemProps, OutlineViewItemSta
if (this.props.item.onDelete) {
menu.append(
new MenuItem({
label: `${localized(`Delete`)} ${contextMenuLabel}`,
label: `${localized('Delete')} ${contextMenuLabel}`,
click: this._onDelete,
})
);
}
menu.append(
new MenuItem({
label: localized('Export the %@ folder', item.name ?? ''),
click: this._onExport,
})
);
menu.popup({});
};

View file

@ -27,6 +27,7 @@ export interface IOutlineViewItem {
onSelect?: (...args: any[]) => any;
onDelete?: (...args: any[]) => any;
onEdited?: (...args: any[]) => any;
onExport?: (...args: any[]) => any;
}
interface OutlineViewProps {
@ -184,7 +185,7 @@ export class OutlineView extends Component<OutlineViewProps, OutlineViewState> {
_renderHeading(allowCreate, collapsed, collapsible) {
const collapseLabel = collapsed ? localized('Show') : localized('Hide');
let style: CSSProperties = {}
let style: CSSProperties = {};
if (this.props.titleColor) {
style = {
height: '50%',
@ -192,7 +193,7 @@ export class OutlineView extends Component<OutlineViewProps, OutlineViewState> {
borderLeftWidth: '4px',
borderLeftColor: this.props.titleColor,
borderLeftStyle: 'solid',
}
};
}
return (
<DropZone

View file

@ -177,6 +177,11 @@ export class Message extends ModelWithMetadata {
modelKey: 'folder',
itemClass: Folder,
}),
remoteFolderId: Attributes.String({
queryable: true,
modelKey: 'remoteFolderId',
}),
};
public subject: string;
@ -198,6 +203,7 @@ export class Message extends ModelWithMetadata {
public replyToHeaderMessageId: string;
public forwardedHeaderMessageId: string;
public folder: Folder;
public remoteFolderId: string;
/** indicates that "body" is plain text, not HTML */
public plaintext: boolean;

View file

@ -317,6 +317,20 @@ const RegExpUtils = {
subcategorySplitRegex() {
return /[./\\]/g;
},
// Finds illegal path characters
// https://kb.acronis.com/content/39790
illegalPathCharacters() {
return /[/?<>\\:*|"]/g;
},
// Finds Unicode Control codes
// C0 0x00-0x1f & C1 (0x80-0x9f)
// http://en.wikipedia.org/wiki/C0_and_C1_control_codes
unicodeControlCharacters() {
// eslint-disable-next-line no-control-regex
return /[\x00-\x1f\x80-\x9f]/g;
},
};
export default RegExpUtils;