Skip to content

Instantly share code, notes, and snippets.

@milch
Last active April 8, 2024 01:38
Show Gist options
  • Star 4 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save milch/f775ab001069da477ca81fd6704b6d4c to your computer and use it in GitHub Desktop.
Save milch/f775ab001069da477ca81fd6704b6d4c to your computer and use it in GitHub Desktop.
Ruby script to convert a mail (.eml/.emlx) file to a note in macOS Notes.app.
require 'base64'
require 'digest'
require 'mail'
require 'nokogiri'
require 'shellwords'
file = ARGV.first
puts "Processing file #{file}"
contents = File.read(file)
mail = Mail.read(file)
subject = mail.subject
date = mail.date.to_time.utc
from = mail.from.first
folder = (subject.match(/#([\w ]*)/).captures.first.chomp rescue "Notes").capitalize
searchID = date.strftime("%s")
subject = subject.sub(/\s*#[\w ]*$/, "")
puts "Creating new Note with title \"#{subject}\" in folder \"#{folder}\""
puts "Received on #{date} from #{from}"
match = contents.scan(/filename\s*=\s*([^\n]*)\n[\s\S]*Content-Id:\s*<([^>]+)>/mi)
attachments = Dir.chdir(File.dirname(File.dirname(file))) do
attachment_globs = match.map { |f, _id| "**/#{f}" }
hashes = Dir[*attachment_globs].map { |found|
{ match.detect { |f, _| found.end_with? f }[1].downcase => File.join(Dir.pwd, found)}
}
hashes.reduce({}, &:merge)
end if match
attachments ||= {}
html = Nokogiri::HTML((mail.html_part || mail.text_part || mail.body).decoded) { |c| c.strict }
abort "Couldn't find a valid html body" unless html
body = html.css('body').first
abort "The body doesn't actually contain any body tags, aborting..." unless body.to_s.include? "<body"
linked_attachment_count = 0
attachment_nodes = body.css('img')
attachment_nodes.each do |a|
next unless a['src'].start_with? "cid"
content_id = a['src'].downcase.sub(/^cid:/, "")
path = attachments[content_id]
mime_type = `file --brief --mime-type #{path.shellescape}`.chomp
a.add_next_sibling "<en-media hash=\"#{Digest::MD5.hexdigest(File.read(path))}\" type=\"#{mime_type}\" />"
a.remove
linked_attachment_count += 1
end
abort "There were more attachments in the source than in the actual HTML (#{attachments.length} > #{linked_attachment_count})" if attachments.length > linked_attachment_count
note_xml = Nokogiri::XML::Builder.new do |xml|
xml.send(:"en-note") {
xml.div {
xml.p
}
xml.font(searchID, color: "#FFFFFF", size: "1", style: "font-size:1px;")
}
end.doc
note_xml.css('p').first.add_child body.inner_html
inner_xml = <<~XML
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE en-note SYSTEM "http://xml.evernote.com/pub/enml2.dtd">
#{note_xml.root.to_xml}
XML
abort "Couldn't create the XML from the provided string" unless inner_xml
outer_xml = Nokogiri::XML(<<~XML, nil, "UTF-8")
<!DOCTYPE en-export SYSTEM "http://xml.evernote.com/pub/evernote-export3.dtd">
<en-export export-date="#{date.strftime("%Y%m%dT%H%M%SZ")}" application="RubyExporter" version="1">
</en-export>
XML
Nokogiri::XML::Builder.with(outer_xml.at('en-export')) do |xml|
xml.note {
xml.title subject
xml.content { xml.cdata inner_xml }
xml.created date.strftime("%Y%m%dT%H%M%SZ")
xml.updated date.strftime("%Y%m%dT%H%M%SZ")
xml.send(:"note-attributes") {
xml.author from
xml.source "Mail"
xml.send(:"reminder-order", 0)
}
attachments.each do |_id, path|
xml.resource {
xml.data(Base64.encode64(File.read(path)), encoding: "base64")
xml.mime `file --brief --mime-type #{path.shellescape}`.chomp
xml.send(:"resource-attributes") {
xml.send(:"file-name", File.basename(path))
xml.timestamp "19700101T000000Z"
}
}
end
}
end
result = outer_xml.to_xml
abort "Couldn't export the generated XML: #{outer_xml}" unless result
result_file = File.expand_path File.join("~", "export.enex")
File.write(result_file, result)
import_script = <<EOF
set pathToImportItem to "#{result_file}"
set sortIntoFolder to "#{folder}"
set searchID to "#{searchID}"
set mailFilePath to "Macintosh HD#{file.tr("/", ":")}"
tell application "System Events"
tell application "Notes" to activate
click menu item "Import to Notes…" of (process "Notes")'s (menu bar 1)'s (menu "File")
delay 1.5
keystroke "g" using {shift down, command down}
delay 1.5
keystroke pathToImportItem
delay 1
keystroke return
delay 1
keystroke return
delay 1
keystroke return
delay 1
end tell
tell application "Notes"
set newNote to the first note whose body contains searchID
set myFolder to first folder whose name = sortIntoFolder
if not (exists folder sortIntoFolder) then
make new folder with properties {name:sortIntoFolder}
end if
move newNote to myFolder
delay 0.5
delete (every folder whose name starts with "Imported Notes")
end tell
tell application "Finder"
set comment of (mailFilePath as alias) to "converted to note"
end tell
EOF
import_script.gsub!(/^\s+/, "") # Remove whitespace
script_lines = import_script.lines.delete_if { |l| l.empty? }
puts `osascript #{script_lines.map { |l| "-e #{l.shellescape}" }.join " "} 2>&1`
@milch
Copy link
Author

milch commented Apr 18, 2017

Note: needs the nokogiri and mail gem to work.

@milch
Copy link
Author

milch commented Apr 18, 2017

This works by taking the mail, converting it to HTML and then saving it as an Evernote .enex file. Notes.app can import such files natively, so all it has to do with that is use an AppleScript to actually run the import.

Unfortunately, importing is not supported in the regular Notes.app AppleScript suite, so this script actually has to click through the relevant menu items. Make sure Hazel or whatever you're using to run this actually has permissions to control your Mac (in the Privacy > Accessibility system preferences).

The subject of the mail is used as the title of the note, and it even supports moving the note to a specified folder, e.g. the subject "My new note #Misc" will be created in the "Misc" folder with the name "My new note".

@milch
Copy link
Author

milch commented Apr 22, 2017

Update: I improved the reliability by doing the following:

  1. After the note was successfully exported it adds a comment to the .eml file in Finder. This is useful if the rule was executed while the mac was sleeping
  2. The note body receives a timestamp at the end (with a white font colour), and the note will be found through that. This is much better than trying to get the note by title, since Notes sometimes changes the title from what was provided, and has trouble getting the correct note when there are multiple notes with the same title.

Here's a screenshot of the Hazel rule:

screen shot 2017-04-22 at 14 15 22

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment