Skip to content

Commit

Permalink
Attachments (#48)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
rmsrosa committed Aug 20, 2021
1 parent ef324e9 commit 0a9dec6
Show file tree
Hide file tree
Showing 7 changed files with 424 additions and 13 deletions.
12 changes: 7 additions & 5 deletions Project.toml
Original file line number Diff line number Diff line change
@@ -1,19 +1,21 @@
name = "SMTPClient"
uuid = "c35d69d1-b747-5018-a192-25bc5e63c83d"
authors = ["Avik Sengupta <avik@sengupta.net>", "Iblis Lin <iblis@hs.ntnu.edu.tw>"]
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"]
67 changes: 59 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/).
Expand Down Expand Up @@ -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"*
Expand All @@ -65,13 +65,64 @@ body = "Subject: A simple test\r\n"*
</html>\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 = ["<foo@test.com>"]
cc = ["<bar@test.com>"]
bcc = ["<baz@test.com>"]
from = "You <you@test.com>"
replyto = "<you@gmail.com>"
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
- <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.)
Expand All @@ -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 = "")
Expand Down
5 changes: 5 additions & 0 deletions src/SMTPClient.jl
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
130 changes: 130 additions & 0 deletions src/mime_types.jl
Original file line number Diff line number Diff line change
@@ -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"
]
)
108 changes: 108 additions & 0 deletions src/user.jl
Original file line number Diff line number Diff line change
@@ -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
1 change: 1 addition & 0 deletions test/runtests.jl
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using Test
import Base64: base64decode
using SMTPClient


Expand Down
Loading

2 comments on commit 0a9dec6

@aviks
Copy link
Owner

@aviks aviks commented on 0a9dec6 Nov 10, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@JuliaRegistrator
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Registration pull request created: JuliaRegistries/General/48548

After the above pull request is merged, it is recommended that a tag is created on this repository for the registered package version.

This will be done automatically if the Julia TagBot GitHub Action is installed, or can be done manually through the github interface, or via:

git tag -a v0.6.1 -m "<description of version>" 0a9dec6cecf635c899a9808fd69d1acc3a0a3bf7
git push origin v0.6.1

Please sign in to comment.