From 0a9dec6cecf635c899a9808fd69d1acc3a0a3bf7 Mon Sep 17 00:00:00 2001 From: Ricardo Rosa Date: Fri, 20 Aug 2021 09:31:17 -0300 Subject: [PATCH] Attachments (#48) * add body and attachment constructor * improve formatting * add tests * formatting * linting * rename `get_message` to `get_mime_message` * add dispatch to `get_mime_msg` only with msg * make it "attachments" (plural) * update README * mention and link rfc5322 * fix date in `get_body` to use locale current time * fix From field * fix From field * fix From field * fix Date field formatting * add comment about bcc * typos * discard extra test file --- Project.toml | 12 +++-- README.md | 67 +++++++++++++++++++++--- src/SMTPClient.jl | 5 ++ src/mime_types.jl | 130 ++++++++++++++++++++++++++++++++++++++++++++++ src/user.jl | 108 ++++++++++++++++++++++++++++++++++++++ test/runtests.jl | 1 + test/send.jl | 114 ++++++++++++++++++++++++++++++++++++++++ 7 files changed, 424 insertions(+), 13 deletions(-) create mode 100644 src/mime_types.jl create mode 100644 src/user.jl diff --git a/Project.toml b/Project.toml index 6e7b37c..885aac6 100644 --- a/Project.toml +++ b/Project.toml @@ -1,19 +1,21 @@ name = "SMTPClient" uuid = "c35d69d1-b747-5018-a192-25bc5e63c83d" authors = ["Avik Sengupta ", "Iblis Lin "] -version = "0.6.0" +version = "0.6.1" [deps] +Base64 = "2a0f44e3-6c83-55bd-87e4-b1978d98bd5f" +Dates = "ade2ca70-3891-5945-98fb-dc099432e06a" Distributed = "8ba89e20-285c-5b6f-9357-94700520ee1b" LibCURL = "b27032c2-a3e7-50c8-80cd-2d36dbcbfd21" Sockets = "6462fe0b-24de-5631-8697-dd941f90decc" -[extras] -Test = "8dfed614-e22c-5e08-85e1-65c5234f0b40" - [compat] -julia = "1.3" LibCURL = "0.6" +julia = "1.3" + +[extras] +Test = "8dfed614-e22c-5e08-85e1-65c5234f0b40" [targets] test = ["Test"] diff --git a/README.md b/README.md index f0dffe8..20ec485 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,6 @@ [![Pkg Eval](https://juliahub.com/docs/SMTPClient/pkgeval.svg)](https://juliahub.com/ui/Packages/SMTPClient/Bx8Fn/) [![Dependents](https://juliahub.com/docs/SMTPClient/deps.svg)](https://juliahub.com/ui/Packages/SMTPClient/Bx8Fn/?t=2) - A [CURL](curl.haxx.se) based SMTP client with fairly low level API. It is useful for sending emails from within Julia code. Depends on [LibCURL.jl](https://github.com/JuliaWeb/LibCURL.jl/). @@ -51,7 +50,8 @@ resp = send(url, rcpt, from, body, opt) ``` ### Example with HTML Formatting -``` + +```julia body = "Subject: A simple test\r\n"* "Mime-Version: 1.0;\r\n"* "Content-Type: text/html;\r\n"* @@ -65,13 +65,64 @@ body = "Subject: A simple test\r\n"* \r\n""" ``` +### Function to construct the IOBuffer body and for adding attachments + +A new function `get_body()` is available to facilitate constructing the IOBuffer for the body of the message and for adding attachments. + +The function takes four required arguments: the `to` and `from` email addresses, a `subject` string, and a `msg` string. The `to` argument is a vector of strings, containing one or more email addresses. The `msg` string can be a regular string with the contents of the message or a string in MIME format, following the [RFC5322](https://datatracker.ietf.org/doc/html/rfc5322) specifications. + +There are also the optional keyword arguments `cc`, `replyto` and `attachments`. The argument `cc` should be a vector of strings, containing one or more email addresses, while `replyto` is a string expected to contain a single argument, just like `from`. The `attachments` argument should be a list of filenames to be attached to the message. + +The attachments are encoded using `Base64.base64encode` and included in the IOBuffer variable returned by the function. The function `get_body()` takes care of identifying which type of attachments are to be included (from the filename extensions) and to properly add them according to the MIME specifications. + +In case an attachment is to be added, the `msg` argument must be formatted according to the MIME specifications. In order to help with that, another function, `get_mime_msg(message)`, is provided, which takes the provided message and returns the message with the proper MIME specifications. By default, it assumes plain text with UTF-8 encoding, but plain text with different encodings or HTML text can also be given can be given (see [src/user.jl#L36](src/user.jl#L35) for the arguments). + +As for blind carbon copy (Bcc), it is implicitly handled by `send()`. Every recipient in `send()` which is not included in `body` is treated as a Bcc. + +Here are two examples: + +```julia +using SMTPClient + +opt = SendOptions( + isSSL = true, + username = "you@gmail.com", + passwd = "yourgmailpassword" +) + +url = "smtps://smtp.gmail.com:465" + +message = "Don't forget to check out SMTPClient.jl" +subject = "SMPTClient.jl" + +to = [""] +cc = [""] +bcc = [""] +from = "You " +replyto = "" + +body = get_body(to, from, subject, message; cc, replyto) + +rcpt = vcat(to, cc, bcc) +resp = send(url, rcpt, from, body, opt) +``` + +```julia +message = "Check out this cool logo!" +subject = "Julia logo" +attachments = ["julia_logo_color.svg"] + +mime_msg = get_mime_msg(message) + +body = get_body(to, from, subject, mime_msg; attachments) +``` ### Gmail Notes Due to the security policy of Gmail, you need to "allow less secure apps into your account": -- https://myaccount.google.com/lesssecureapps +- The URL for gmail can be either `smtps://smtp.gmail.com:465` or `smtp://smtp.gmail.com:587`. (Note the extra `s` in the former.) @@ -98,12 +149,12 @@ send(url, to-addresses, from-address, message-body, options) ``` Send an email. - * `url` should be of the form `smtp://server:port` or `smtps://...`. - * `to-address` is a vector of `String`. - * `from-address` is a `String`. All addresses must be enclosed in angle brackets. - * `message-body` must be a RFC5322 formatted message body provided via an `IO`. - * `options` is an object of type `SendOptions`. It contains authentication information, as well as the option of whether the server requires TLS. +* `url` should be of the form `smtp://server:port` or `smtps://...`. +* `to-address` is a vector of `String`. +* `from-address` is a `String`. All addresses must be enclosed in angle brackets. +* `message-body` must be a RFC5322 formatted message body provided via an `IO`. +* `options` is an object of type `SendOptions`. It contains authentication information, as well as the option of whether the server requires TLS. ```julia SendOptions(; isSSL = false, verbose = false, username = "", passwd = "") diff --git a/src/SMTPClient.jl b/src/SMTPClient.jl index 80d66fa..49bacbc 100644 --- a/src/SMTPClient.jl +++ b/src/SMTPClient.jl @@ -2,16 +2,21 @@ module SMTPClient using Distributed using LibCURL +using Dates +using Base64 import Base: convert import Sockets: send export SendOptions, SendResponse, send +export get_body, get_mime_msg include("utils.jl") include("types.jl") include("cbs.jl") # callbacks include("mail.jl") +include("mime_types.jl") +include("user.jl") ############################## # Module init/cleanup diff --git a/src/mime_types.jl b/src/mime_types.jl new file mode 100644 index 0000000..455c832 --- /dev/null +++ b/src/mime_types.jl @@ -0,0 +1,130 @@ +mime_types = Dict( + [ + "abs" => "audio/x-mpeg" + "ai" => "application/postscript" + "aif" => "audio/x-aiff" + "aifc" => "audio/x-aiff" + "aiff" => "audio/x-aiff" + "aim" => "application/x-aim" + "art" => "image/x-jg" + "asf" => "video/x-ms-asf" + "asx" => "video/x-ms-asf" + "au" => "audio/basic" + "avi" => "video/x-msvideo" + "avx" => "video/x-rad-screenplay" + "bcpio" => "application/x-bcpio" + "bin" => "application/octet-stream" + "bmp" => "image/bmp" + "body" => "text/html" + "cdf" => "application/x-cdf" + "cer" => "application/x-x509-ca-cert" + "class" => "application/java" + "cpio" => "application/x-cpio" + "csh" => "application/x-csh" + "css" => "text/css" + "dib" => "image/bmp" + "doc" => "application/msword" + "dtd" => "text/plain" + "dv" => "video/x-dv" + "dvi" => "application/x-dvi" + "eps" => "application/postscript" + "etx" => "text/x-setext" + "exe" => "application/octet-stream" + "gif" => "image/gif" + "gtar" => "application/x-gtar" + "gz" => "application/x-gzip" + "hdf" => "application/x-hdf" + "hqx" => "application/mac-binhex40" + "htc" => "text/x-component" + "htm" => "text/html" + "html" => "text/html" + "ief" => "image/ief" + "jad" => "text/vnd.sun.j2me.app-descriptor" + "jar" => "application/octet-stream" + "java" => "text/plain" + "jnlp" => "application/x-java-jnlp-file" + "jpe" => "image/jpeg" + "jpeg" => "image/jpeg" + "jpg" => "image/jpeg" + "js" => "text/javascript" + "kar" => "audio/x-midi" + "latex" => "application/x-latex" + "m3u" => "audio/x-mpegurl" + "mac" => "image/x-macpaint" + "man" => "application/x-troff-man" + "me" => "application/x-troff-me" + "mid" => "audio/x-midi" + "midi" => "audio/x-midi" + "mif" => "application/x-mif" + "mov" => "video/quicktime" + "movie" => "video/x-sgi-movie" + "mp1" => "audio/x-mpeg" + "mp2" => "audio/x-mpeg" + "mp3" => "audio/x-mpeg" + "mpa" => "audio/x-mpeg" + "mpe" => "video/mpeg" + "mpeg" => "video/mpeg" + "mpega" => "audio/x-mpeg" + "mpg" => "video/mpeg" + "mpv2" => "video/mpeg2" + "ms" => "application/x-wais-source" + "nc" => "application/x-netcdf" + "oda" => "application/oda" + "pbm" => "image/x-portable-bitmap" + "pct" => "image/pict" + "pdf" => "application/pdf" + "pgm" => "image/x-portable-graymap" + "pic" => "image/pict" + "pict" => "image/pict" + "pls" => "audio/x-scpls" + "png" => "image/png" + "pnm" => "image/x-portable-anymap" + "pnt" => "image/x-macpaint" + "ppm" => "image/x-portable-pixmap" + "ps" => "application/postscript" + "psd" => "image/x-photoshop" + "qt" => "video/quicktime" + "qti" => "image/x-quicktime" + "qtif" => "image/x-quicktime" + "ras" => "image/x-cmu-raster" + "rgb" => "image/x-rgb" + "rm" => "application/vnd.rn-realmedia" + "roff" => "application/x-troff" + "rtf" => "application/rtf" + "rtx" => "text/richtext" + "sh" => "application/x-sh" + "shar" => "application/x-shar" + "smf" => "audio/x-midi" + "snd" => "audio/basic" + "src" => "application/x-wais-source" + "sv4cpio" => "application/x-sv4cpio" + "sv4crc" => "application/x-sv4crc" + "swf" => "application/x-shockwave-flash" + "t" => "application/x-troff" + "tar" => "application/x-tar" + "tcl" => "application/x-tcl" + "tex" => "application/x-tex" + "texi" => "application/x-texinfo" + "texinfo" => "application/x-texinfo" + "tif" => "image/tiff" + "tiff" => "image/tiff" + "tr" => "application/x-troff" + "tsv" => "text/tab-separated-values" + "txt" => "text/plain" + "ulw" => "audio/basic" + "ustar" => "application/x-ustar" + "xbm" => "image/x-xbitmap" + "xpm" => "image/x-xpixmap" + "xwd" => "image/x-xwindowdump" + "wav" => "audio/x-wav" + "wbmp" => "image/vnd.wap.wbmp" + "wml" => "text/vnd.wap.wml" + "wmlc" => "application/vnd.wap.wmlc" + "wmls" => "text/vnd.wap.wmlscript" + "wmlscriptc" => "application/vnd.wap.wmlscriptc" + "wrl" => "x-world/x-vrml" + "Z" => "application/x-compress" + "z" => "application/x-compress" + "zip" => "application/zip" + ] +) \ No newline at end of file diff --git a/src/user.jl b/src/user.jl new file mode 100644 index 0000000..387c857 --- /dev/null +++ b/src/user.jl @@ -0,0 +1,108 @@ +function encode_attachment(filename::String, boundary::String) + io = IOBuffer() + iob64_encode = Base64EncodePipe(io) + open(filename, "r") do f + write(iob64_encode, f) + end + close(iob64_encode) + + filename_ext = split(filename, '.')[end] + + if haskey(mime_types, filename_ext) + content_type = mime_types[filename_ext] + else + content_type = "application/octet-stream" + end + + if haskey(mime_types, filename_ext) && startswith(mime_types[filename_ext], "image") + content_disposition = "inline" + else + content_disposition = "attachment" + end + + encoded_str = + "--$boundary\r\n" * + "Content-Disposition: $content_disposition;\r\n" * + " filename=$(basename(filename))\r\n" * + "Content-Type: $content_type;\r\n" * + " name=\"$(basename(filename))\"\r\n" * + "Content-Transfer-Encoding: base64\r\n" * + "$(String(take!(io)))\r\n" * + "--$boundary\r\n" + return encoded_str +end + +# See https://www.w3.org/Protocols/rfc1341/7_1_Text.html about charset +function get_mime_msg(message::String, ::Val{:plain}, charset::String = "UTF-8") + msg = + "Content-Type: text/plain; charset=\"$charset\"" * + "Content-Transfer-Encoding: quoted-printable\r\n\r\n" * + "$message\r\n" + return msg +end + +get_mime_msg(message::String, ::Val{:utf8}) = + get_mime_msg(message, Val(:plain), "UTF-8") + +get_mime_msg(message::String, ::Val{:usascii}) = + get_mime_msg(message, Val(:plain), "US-ASCII") + +get_mime_msg(message::String) = get_mime_msg(message, Val(:utf8)) + +function get_mime_msg(message::String, ::Val{:html}) + msg = + "Content-Type: text/html;\r\n" * + "Content-Transfer-Encoding: 7bit;\r\n\r\n" * + "\r\n" * + message * + "\r\n" + return msg +end + +#Provide the message body as RFC5322 within an IO + +function get_body( + to::Vector{String}, + from::String, + subject::String, + msg::String; + cc::Vector{String} = String[], + replyto::String = "", + attachments::Vector{String} = String[] + ) + + boundary = "Julia_SMTPClient-" * join(rand(collect(vcat('0':'9','A':'Z','a':'z')), 40)) + + tz = mapreduce( + x -> string(x, pad=2), *, + divrem( div( ( now() - now(Dates.UTC) ).value, 60000 ), 60 ) + ) + date = join([Dates.format(now(), "e, d u yyyy HH:MM:SS", locale="english"), tz], " ") + + contents = + "From: $from\r\n" * + "Date: $date\r\n" * + "Subject: $subject\r\n" * + ifelse(length(cc) > 0, "Cc: $(join(cc, ", "))\r\n", "") * + ifelse(length(replyto) > 0, "Reply-To: $replyto\r\n", "") * + "To: $(join(to, ", "))\r\n" + + if length(attachments) == 0 + contents *= + "MIME-Version: 1.0\r\n" * + "$msg\r\n\r\n" + else + contents *= + "Content-Type: multipart/mixed; boundary=\"$boundary\"\r\n\r\n" * + "MIME-Version: 1.0\r\n" * + "\r\n" * + "This is a message with multiple parts in MIME format.\r\n" * + "--$boundary\r\n" * + "$msg\r\n" * + "--$boundary\r\n" * + "\r\n" * + join(encode_attachment.(attachments, boundary), "\r\n") + end + body = IOBuffer(contents) + return body +end \ No newline at end of file diff --git a/test/runtests.jl b/test/runtests.jl index c1d1133..c00bdf8 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -1,4 +1,5 @@ using Test +import Base64: base64decode using SMTPClient diff --git a/test/send.jl b/test/send.jl index b911d3e..147026b 100644 --- a/test/send.jl +++ b/test/send.jl @@ -86,6 +86,120 @@ end end end + let # send using get_body + message = "body mime message" + subject = "test message" + + mime_message = get_mime_msg(message, Val(:usascii)) + body = get_body([addr], addr, subject, mime_message) + + send(server, [addr], addr, body) + + test_content(logfile) do s + @test occursin("From: $addr", s) + @test occursin("To: $addr", s) + @test occursin("Subject: $subject", s) + @test occursin(message, s) + end + end + + let # send using get_body with UTF-8 encoded message + message = + "ABCDEFGHIJKLMNOPQRSTUVWXYZ /0123456789\r\n" * + "abcdefghijklmnopqrstuvwxyz £©µÀÆÖÞßéöÿ\r\n" * + "–—‘“”„†•…‰™œŠŸž€ ΑΒΓΔΩαβγδω АБВГДабвгд\r\n" * + "∀∂∈ℝ∧∪≡∞ ↑↗↨↻⇣ ┐┼╔╘░►☺♀ fi�⑀₂ἠḂӥẄɐː⍎אԱა\r\n" + subject = "test message in UTF-8" + + mime_message = get_mime_msg(message, Val(:utf8)) + body = get_body([addr], addr, subject, mime_message) + + send(server, [addr], addr, body) + + test_content(logfile) do s + @test occursin("From: $addr", s) + @test occursin("To: $addr", s) + @test occursin("Subject: $subject", s) + @test occursin("ABCDEFGHIJKLMNOPQRSTUVWXYZ /0123456789", s) + @test occursin("abcdefghijklmnopqrstuvwxyz £©µÀÆÖÞßéöÿ", s) + @test occursin("–—‘“”„†•…‰™œŠŸž€ ΑΒΓΔΩαβγδω АБВГДабвгд", s) + @test occursin("∀∂∈ℝ∧∪≡∞ ↑↗↨↻⇣ ┐┼╔╘░►☺♀ fi�⑀₂ἠḂӥẄɐː⍎אԱა", s) + end + end + + let # send using get_body with extra fields + message = "body mime message with extra fields" + subject = "test message with extra fields" + + mime_message = get_mime_msg(message) + from = addr + to = ["", ""] + cc = ["", ""] + bcc = [""] + replyto = addr + body = get_body(to, from, subject, mime_message; + cc = cc, replyto = replyto) + rcpts = vcat(to, cc, bcc) + + send(server, rcpts, addr, body) + + test_content(logfile) do s + @test occursin("From: $addr", s) + @test occursin("Subject: $subject", s) + @test occursin("Cc: , ", s) + @test occursin("Reply-To: $addr", s) + @test occursin("To: , ", s) + @test occursin(message, s) + @test occursin("X-RCPT: foo@example.org", s) + @test occursin("X-RCPT: bar@example.org", s) + @test occursin("X-RCPT: baz@example.org", s) + @test occursin("X-RCPT: qux@example.org", s) + @test occursin("X-RCPT: quux@example.org", s) + end + end + + let # send with attachment + message = "body mime message with attachment" + subject = "test message with attachment" + svg_str = """ + + + + + + + + + + """ + filename = joinpath(tempdir(), "julia_logo_color.svg") + open(filename, "w") do f + write(f, svg_str) + end + readme = open(f->read(f, String), joinpath("..", "README.md")) + + mime_message = get_mime_msg(message, Val(:utf8)) + attachments = [joinpath("..", "README.md"), filename] + body = get_body([addr], addr, subject, mime_message, attachments = attachments) + + send(server, [addr], addr, body) + + test_content(logfile) do s + m = match(r"Content-Type:\s*multipart\/mixed;\s*boundary=\"(.+)\"\n", s) + @test m !== nothing + boundary = m.captures[1] + @test occursin("To: $addr", s) + @test occursin("Subject: $subject", s) + @test occursin(message, s) + splt = split(s) + ind = findall(v -> occursin("--$boundary", v), splt) + @test length(ind) == 6 + @test String(base64decode(splt[ind[4]-1])) == readme + @test String(base64decode(splt[ind[6]-1])) == svg_str + end + rm(filename) + end + finally kill(smtpsink) rm(logfile, force = true)