mirror of
https://github.com/Foundry376/Mailspring.git
synced 2025-09-06 12:44:30 +08:00
Summary: initial styling of image attachments more styles for composer overflow style composer toolbar toolbar styling Fixes to inline composer Test Plan: edgehill --test Reviewers: bengotow Reviewed By: bengotow Differential Revision: https://phab.nylas.com/D1647
432 lines
14 KiB
CoffeeScript
432 lines
14 KiB
CoffeeScript
_ = require 'underscore'
|
|
moment = require 'moment'
|
|
ipc = require 'ipc'
|
|
|
|
DraftStoreProxy = require './draft-store-proxy'
|
|
DatabaseStore = require './database-store'
|
|
NamespaceStore = require './namespace-store'
|
|
ContactStore = require './contact-store'
|
|
|
|
SendDraftTask = require '../tasks/send-draft'
|
|
DestroyDraftTask = require '../tasks/destroy-draft'
|
|
|
|
Thread = require '../models/thread'
|
|
Contact = require '../models/contact'
|
|
Message = require '../models/message'
|
|
MessageUtils = require '../models/message-utils'
|
|
Actions = require '../actions'
|
|
|
|
TaskQueue = require './task-queue'
|
|
|
|
{subjectWithPrefix, generateTempId} = require '../models/utils'
|
|
|
|
{Listener, Publisher} = require '../modules/reflux-coffee'
|
|
CoffeeHelpers = require '../coffee-helpers'
|
|
|
|
###
|
|
Public: DraftStore responds to Actions that interact with Drafts and exposes
|
|
public getter methods to return Draft objects and sessions.
|
|
|
|
It also creates and queues {Task} objects to persist changes to the Nylas
|
|
API.
|
|
|
|
Remember that a "Draft" is actually just a "Message" with `draft: true`.
|
|
|
|
Section: Drafts
|
|
###
|
|
class DraftStore
|
|
@include: CoffeeHelpers.includeModule
|
|
|
|
@include Publisher
|
|
@include Listener
|
|
|
|
constructor: ->
|
|
@listenTo DatabaseStore, @_onDataChanged
|
|
|
|
@listenTo Actions.composeReply, @_onComposeReply
|
|
@listenTo Actions.composeForward, @_onComposeForward
|
|
@listenTo Actions.composeReplyAll, @_onComposeReplyAll
|
|
@listenTo Actions.composePopoutDraft, @_onPopoutDraftLocalId
|
|
@listenTo Actions.composeNewBlankDraft, @_onPopoutBlankDraft
|
|
|
|
atom.commands.add 'body',
|
|
'application:new-message': => @_onPopoutBlankDraft()
|
|
|
|
# Remember that these two actions only fire in the current window and
|
|
# are picked up by the instance of the DraftStore in the current
|
|
# window.
|
|
@listenTo Actions.sendDraft, @_onSendDraft
|
|
@listenTo Actions.destroyDraft, @_onDestroyDraft
|
|
|
|
@listenTo Actions.removeFile, @_onRemoveFile
|
|
|
|
atom.onBeforeUnload @_onBeforeUnload
|
|
|
|
@_draftSessions = {}
|
|
@_extensions = []
|
|
|
|
ipc.on 'mailto', @_onHandleMailtoLink
|
|
|
|
# TODO: Doesn't work if we do window.addEventListener, but this is
|
|
# fragile. Pending an Atom fix perhaps?
|
|
|
|
######### PUBLIC #######################################################
|
|
|
|
# Public: Fetch a {DraftStoreProxy} for displaying and/or editing the
|
|
# draft with `localId`.
|
|
#
|
|
# Example:
|
|
#
|
|
# ```coffee
|
|
# session = DraftStore.sessionForLocalId(localId)
|
|
# session.prepare().then ->
|
|
# # session.draft() is now ready
|
|
# ```
|
|
#
|
|
# - `localId` The {String} local ID of the draft.
|
|
#
|
|
# Returns a {Promise} that resolves to an {DraftStoreProxy} for the
|
|
# draft once it has been prepared:
|
|
sessionForLocalId: (localId) =>
|
|
if not localId
|
|
console.log((new Error).stack)
|
|
throw new Error("sessionForLocalId requires a localId")
|
|
@_draftSessions[localId] ?= new DraftStoreProxy(localId)
|
|
@_draftSessions[localId].prepare()
|
|
|
|
# Public: Look up the sending state of the given draft Id.
|
|
# In popout windows the existance of the window is the sending state.
|
|
isSendingDraft: (draftLocalId) ->
|
|
if atom.isMainWindow()
|
|
task = TaskQueue.findTask(SendDraftTask, {draftLocalId})
|
|
return task?
|
|
else return false
|
|
|
|
###
|
|
Composer Extensions
|
|
###
|
|
|
|
# Public: Returns the extensions registered with the DraftStore.
|
|
extensions: (ext) =>
|
|
@_extensions
|
|
|
|
# Public: Registers a new extension with the DraftStore. DraftStore extensions
|
|
# make it possible to extend the editor experience, modify draft contents,
|
|
# display warnings before draft are sent, and more.
|
|
#
|
|
# - `ext` A {DraftStoreExtension} instance.
|
|
#
|
|
registerExtension: (ext) =>
|
|
@_extensions.push(ext)
|
|
|
|
# Public: Unregisters the extension provided from the DraftStore.
|
|
#
|
|
# - `ext` A {DraftStoreExtension} instance.
|
|
#
|
|
unregisterExtension: (ext) =>
|
|
@_extensions = _.without(@_extensions, ext)
|
|
|
|
########### PRIVATE ####################################################
|
|
|
|
_doneWithSession: (session) ->
|
|
session.cleanup()
|
|
delete @_draftSessions[session.draftLocalId]
|
|
|
|
_onBeforeUnload: =>
|
|
promises = []
|
|
|
|
# Normally we'd just append all promises, even the ones already
|
|
# fulfilled (nothing to save), but in this case we only want to
|
|
# block window closing if we have to do real work. Calling
|
|
# window.close() within on onbeforeunload could do weird things.
|
|
for key, session of @_draftSessions
|
|
if session.draft()?.pristine
|
|
Actions.queueTask(new DestroyDraftTask(session.draftLocalId))
|
|
else
|
|
promise = session.changes.commit()
|
|
promises.push(promise) unless promise.isFulfilled()
|
|
|
|
if promises.length > 0
|
|
Promise.settle(promises).then =>
|
|
@_draftSessions = {}
|
|
atom.close()
|
|
|
|
# Stop and wait before closing
|
|
return false
|
|
else
|
|
# Continue closing
|
|
return true
|
|
|
|
_onDataChanged: (change) =>
|
|
return unless change.objectClass is Message.name
|
|
containsDraft = _.some(change.objects, (msg) -> msg.draft)
|
|
return unless containsDraft
|
|
@trigger(change)
|
|
|
|
_isMe: (contact={}) =>
|
|
contact.email is NamespaceStore.current().me().email
|
|
|
|
_onComposeReply: (context) =>
|
|
@_newMessageWithContext context, (thread, message) =>
|
|
if @_isMe(message.from[0])
|
|
to = message.to
|
|
else if message.replyTo.length
|
|
to = message.replyTo
|
|
else
|
|
to = message.from
|
|
|
|
return {
|
|
replyToMessage: message
|
|
to: to
|
|
}
|
|
|
|
_onComposeReplyAll: (context) =>
|
|
@_newMessageWithContext context, (thread, message) =>
|
|
if @_isMe(message.from[0])
|
|
to = message.to
|
|
cc = message.cc
|
|
else
|
|
excluded = message.from.map (c) -> c.email
|
|
excluded.push(NamespaceStore.current().me().email)
|
|
if message.replyTo.length
|
|
to = message.replyTo
|
|
else
|
|
to = message.from
|
|
cc = [].concat(message.cc, message.to).filter (p) ->
|
|
!_.contains(excluded, p.email)
|
|
|
|
return {
|
|
replyToMessage: message
|
|
to: to
|
|
cc: cc
|
|
}
|
|
|
|
_onComposeForward: (context) =>
|
|
@_newMessageWithContext context, (thread, message) ->
|
|
forwardMessage: message
|
|
|
|
_newMessageWithContext: ({thread, threadId, message, messageId, popout}, attributesCallback) =>
|
|
return unless NamespaceStore.current()
|
|
|
|
# We accept all kinds of context. You can pass actual thread and message objects,
|
|
# or you can pass Ids and we'll look them up. Passing the object is preferable,
|
|
# and in most cases "the data is right there" anyway. Lookups add extra latency
|
|
# that feels bad.
|
|
queries = {}
|
|
|
|
if thread?
|
|
throw new Error("newMessageWithContext: `thread` present, expected a Model. Maybe you wanted to pass `threadId`?") unless thread instanceof Thread
|
|
queries.thread = thread
|
|
else
|
|
queries.thread = DatabaseStore.find(Thread, threadId)
|
|
|
|
if message?
|
|
throw new Error("newMessageWithContext: `message` present, expected a Model. Maybe you wanted to pass `messageId`?") unless message instanceof Message
|
|
queries.message = message
|
|
else if messageId?
|
|
queries.message = DatabaseStore.find(Message, messageId)
|
|
queries.message.include(Message.attributes.body)
|
|
else
|
|
queries.message = DatabaseStore.findBy(Message, {threadId: threadId ? thread.id}).order(Message.attributes.date.descending()).limit(1)
|
|
queries.message.include(Message.attributes.body)
|
|
|
|
# Waits for the query promises to resolve and then resolve with a hash
|
|
# of their resolved values. *swoon*
|
|
Promise.props(queries).then ({thread, message}) =>
|
|
attributes = attributesCallback(thread, message)
|
|
attributes.subject ?= subjectWithPrefix(thread.subject, 'Re:')
|
|
attributes.body ?= ""
|
|
|
|
# A few helpers for formatting
|
|
contactString = (c) ->
|
|
if c.name then "#{c.name} <#{c.email}>" else c.email
|
|
contactStrings = (cs) ->
|
|
_.map(cs, contactString).join(", ")
|
|
messageDate = (d) ->
|
|
moment(d).format("MMM D YYYY, [at] h:mm a")
|
|
|
|
if attributes.replyToMessage
|
|
msg = attributes.replyToMessage
|
|
contact = msg.from[0] ? new Contact(name: 'Unknown', email:'Unknown')
|
|
attribution = "On #{messageDate(msg.date)}, #{contactString(contact)} wrote:"
|
|
|
|
attributes.subject = subjectWithPrefix(msg.subject, 'Re:')
|
|
attributes.replyToMessageId = msg.id
|
|
attributes.body = """
|
|
<br><br><blockquote class="gmail_quote"
|
|
style="margin:0 0 0 .8ex;border-left:1px #ccc solid;padding-left:1ex;">
|
|
#{attribution}
|
|
<br>
|
|
#{@_formatBodyForQuoting(msg.body)}
|
|
</blockquote>"""
|
|
delete attributes.quotedMessage
|
|
|
|
if attributes.forwardMessage
|
|
msg = attributes.forwardMessage
|
|
fields = []
|
|
fields.push("From: #{contactStrings(msg.from)}") if msg.from.length > 0
|
|
fields.push("Subject: #{msg.subject}")
|
|
fields.push("Date: #{messageDate(msg.date)}")
|
|
fields.push("To: #{contactStrings(msg.to)}") if msg.to.length > 0
|
|
fields.push("CC: #{contactStrings(msg.cc)}") if msg.cc.length > 0
|
|
fields.push("BCC: #{contactStrings(msg.bcc)}") if msg.bcc.length > 0
|
|
|
|
if msg.files?.length > 0
|
|
attributes.files ?= []
|
|
attributes.files = attributes.files.concat(msg.files)
|
|
|
|
attributes.subject = subjectWithPrefix(msg.subject, 'Fwd:')
|
|
attributes.body = """
|
|
<br><br><blockquote class="gmail_quote"
|
|
style="margin:0 0 0 .8ex;border-left:1px #ccc solid;padding-left:1ex;">
|
|
Begin forwarded message:
|
|
<br><br>
|
|
#{fields.join('<br>')}
|
|
<br><br>
|
|
#{@_formatBodyForQuoting(msg.body)}
|
|
</blockquote>"""
|
|
delete attributes.forwardedMessage
|
|
|
|
draft = new Message _.extend {}, attributes,
|
|
from: [NamespaceStore.current().me()]
|
|
date: (new Date)
|
|
draft: true
|
|
pristine: true
|
|
threadId: thread.id
|
|
namespaceId: thread.namespaceId
|
|
|
|
# Normally we'd allow the DatabaseStore to create a localId, wait for it to
|
|
# commit a LocalLink and resolve, etc. but it's faster to create one now.
|
|
draftLocalId = generateTempId()
|
|
|
|
# Optimistically create a draft session and hand it the draft so that it
|
|
# doesn't need to do a query for it a second from now when the composer wants it.
|
|
@_draftSessions[draftLocalId] = new DraftStoreProxy(draftLocalId, draft)
|
|
|
|
DatabaseStore.bindToLocalId(draft, draftLocalId)
|
|
DatabaseStore.persistModel(draft).then =>
|
|
Actions.composePopoutDraft(draftLocalId) if popout
|
|
|
|
|
|
# Eventually we'll want a nicer solution for inline attachments
|
|
_formatBodyForQuoting: (body="") =>
|
|
cidRE = MessageUtils.cidRegexString
|
|
# Be sure to match over multiple lines with [\s\S]*
|
|
# Regex explanation here: https://regex101.com/r/vO6eN2/1
|
|
re = new RegExp("<img.*#{cidRE}[\\s\\S]*?>", "igm")
|
|
body.replace(re, "")
|
|
|
|
_onPopoutBlankDraft: =>
|
|
namespace = NamespaceStore.current()
|
|
return unless namespace
|
|
|
|
draft = new Message
|
|
body: ""
|
|
from: [namespace.me()]
|
|
date: (new Date)
|
|
draft: true
|
|
pristine: true
|
|
namespaceId: namespace.id
|
|
|
|
DatabaseStore.persistModel(draft).then =>
|
|
DatabaseStore.localIdForModel(draft).then (draftLocalId, options={}) =>
|
|
options.newDraft = true
|
|
@_onPopoutDraftLocalId(draftLocalId, options)
|
|
|
|
_onPopoutDraftLocalId: (draftLocalId, options = {}) =>
|
|
return unless NamespaceStore.current()
|
|
|
|
save = Promise.resolve()
|
|
if @_draftSessions[draftLocalId]
|
|
save = @_draftSessions[draftLocalId].changes.commit()
|
|
|
|
title = if options.newDraft then "New Message" else "Message"
|
|
|
|
save.then =>
|
|
atom.newWindow
|
|
title: title
|
|
windowType: "composer"
|
|
windowProps: _.extend(options, {draftLocalId})
|
|
|
|
_onHandleMailtoLink: (urlString) =>
|
|
namespace = NamespaceStore.current()
|
|
return unless namespace
|
|
|
|
url = require 'url'
|
|
qs = require 'querystring'
|
|
parts = url.parse(urlString)
|
|
query = qs.parse(parts.query)
|
|
query.to = "#{parts.auth}@#{parts.host}"
|
|
|
|
draft = new Message
|
|
body: query.body || ''
|
|
subject: query.subject || '',
|
|
from: [namespace.me()]
|
|
date: (new Date)
|
|
draft: true
|
|
pristine: true
|
|
namespaceId: namespace.id
|
|
|
|
contactForEmail = (email) ->
|
|
match = ContactStore.searchContacts(email, 1)
|
|
return match[0] if match[0]
|
|
return new Contact({email})
|
|
|
|
for attr in ['to', 'cc', 'bcc']
|
|
draft[attr] = query[attr]?.split(',').map(contactForEmail) || []
|
|
|
|
DatabaseStore.persistModel(draft).then =>
|
|
DatabaseStore.localIdForModel(draft).then(@_onPopoutDraftLocalId)
|
|
|
|
_onDestroyDraft: (draftLocalId) =>
|
|
session = @_draftSessions[draftLocalId]
|
|
|
|
if not session
|
|
throw new Error("Couldn't find the draft session in the current window")
|
|
|
|
# Immediately reset any pending changes so no saves occur
|
|
session.changes.reset()
|
|
|
|
# Queue the task to destroy the draft
|
|
Actions.queueTask(new DestroyDraftTask(draftLocalId))
|
|
|
|
@_doneWithSession(session)
|
|
|
|
atom.close() if @_isPopout()
|
|
|
|
# The user request to send the draft
|
|
_onSendDraft: (draftLocalId) =>
|
|
@sessionForLocalId(draftLocalId).then (session) =>
|
|
@_runExtensionsBeforeSend(session)
|
|
|
|
# Immediately save any pending changes so we don't save after sending
|
|
session.changes.commit().then =>
|
|
|
|
task = new SendDraftTask draftLocalId, {fromPopout: @_isPopout()}
|
|
Actions.queueTask(task)
|
|
|
|
# As far as this window is concerned, we're not making any more
|
|
# edits and are destroying the session. If there are errors down
|
|
# the line, we'll make a new session and handle them later
|
|
@_doneWithSession(session)
|
|
@trigger()
|
|
|
|
atom.close() if @_isPopout()
|
|
|
|
_isPopout: ->
|
|
atom.getWindowType() is "composer"
|
|
|
|
# Give third-party plugins an opportunity to sanitize draft data
|
|
_runExtensionsBeforeSend: (session) ->
|
|
for extension in @_extensions
|
|
continue unless extension.finalizeSessionBeforeSending
|
|
extension.finalizeSessionBeforeSending(session)
|
|
|
|
_onRemoveFile: ({file, messageLocalId}) =>
|
|
@sessionForLocalId(messageLocalId).then (session) ->
|
|
files = _.clone(session.draft().files) ? []
|
|
files = _.reject files, (f) -> f.id is file.id
|
|
session.changes.add({files}, true)
|
|
|
|
|
|
module.exports = new DraftStore()
|