Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Update to use Elixir DateTime module with time zone suppport #153

Merged
merged 5 commits into from
Aug 1, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions .github/workflows/main.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
name: Elixir CI

on:
push:
branches: [master]
pull_request:
branches: [master]

env:
MIX_ENV: test

permissions:
contents: read

jobs:
build:
name: Build and test
runs-on: ubuntu-latest

strategy:
matrix:
elixir: ['1.14.5', '1.15.4']
erlang: ['24.3', '25.3', '26.0']

steps:
- uses: actions/checkout@v3
- name: Set up Elixir
uses: erlef/setup-beam@v1
with:
version-type: 'loose'
elixir-version: ${{ matrix.elixir }}
otp-version: ${{ matrix.erlang }}
- name: Restore dependencies cache
uses: actions/cache@v2
with:
path: deps
key: ${{ runner.os }}-${{ matrix.erlang }}-${{ matrix.elixir }}-mix-${{ hashFiles('**/mix.lock') }}
restore-keys: ${{ runner.os }}-${{ matrix.erlang }}-${{ matrix.elixir }}-mix-
- name: Install dependencies
run: mix deps.get
- name: Run tests
run: mix test
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
# Changelog

## Master

* Add DateTime and time zone support to date parsing/rendering
* Fix compile warnings

## 0.2.3 2021-06-28

* Add support for incorrect case in date parsing https://github.com/DockYard/elixir-mail/pull/132
Expand Down
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
# Mail [![Build Status](https://secure.travis-ci.org/DockYard/elixir-mail.svg?branch=master)](https://travis-ci.org/DockYard/elixir-mail)
# Mail

![Build Status](https://github.com/DockYard/elixir-mail/actions/workflows/main.yml/badge.svg)

An RFC2822 implementation in Elixir, built for composability.

Expand Down
30 changes: 0 additions & 30 deletions config/config.exs

This file was deleted.

181 changes: 116 additions & 65 deletions lib/mail/parsers/rfc_2822.ex
Original file line number Diff line number Diff line change
Expand Up @@ -41,131 +41,189 @@ defmodule Mail.Parsers.RFC2822 do
do: extract_headers(tail, [header | headers])

@doc """
Parses a RFC2822 timestamp to an Erlang timestamp
Parses a RFC2822 timestamp to a DateTime with timezone

[RFC2822 3.3 - Date and Time Specification](https://tools.ietf.org/html/rfc2822#section-3.3)

Timezone information is ignored
"""
def erl_from_timestamp(<<" ", rest::binary>>), do: erl_from_timestamp(rest)
def erl_from_timestamp(<<"\t", rest::binary>>), do: erl_from_timestamp(rest)

def erl_from_timestamp(<<_day::binary-size(3), ", ", rest::binary>>) do
erl_from_timestamp(rest)
end
def to_datetime(<<" ", rest::binary>>), do: to_datetime(rest)
def to_datetime(<<"\t", rest::binary>>), do: to_datetime(rest)
def to_datetime(<<_day::binary-size(3), ", ", rest::binary>>), do: to_datetime(rest)

def erl_from_timestamp(<<date::binary-size(1), " ", rest::binary>>) do
erl_from_timestamp("0" <> date <> " " <> rest)
end
def to_datetime(<<date::binary-size(1), " ", rest::binary>>),
do: to_datetime("0" <> date <> " " <> rest)

# This caters for an invalid date with no 0 before the hour, e.g. 5:21:43 instead of 05:21:43
def erl_from_timestamp(<<date::binary-size(11), " ", hour::binary-size(1), ":", rest::binary>>) do
erl_from_timestamp("#{date} 0#{hour}:#{rest}")
def to_datetime(<<date::binary-size(11), " ", hour::binary-size(1), ":", rest::binary>>) do
to_datetime("#{date} 0#{hour}:#{rest}")
end

# This caters for an invalid date with dashes between the date/month/year parts
def erl_from_timestamp(
def to_datetime(
<<date::binary-size(2), "-", month::binary-size(3), "-", year::binary-size(4),
rest::binary>>
) do
erl_from_timestamp("#{date} #{month} #{year}#{rest}")
to_datetime("#{date} #{month} #{year}#{rest}")
end

def erl_from_timestamp(
# This caters for an invalid two-digit year
def to_datetime(
<<date::binary-size(2), " ", month::binary-size(3), " ", year::binary-size(2), " ",
rest::binary>>
) do
year = year |> String.to_integer() |> to_four_digit_year()
to_datetime("#{date} #{month} #{year} #{rest}")
end

# This caters for missing seconds
def to_datetime(
<<date::binary-size(11), " ", hour::binary-size(2), ":", minute::binary-size(2), " ",
rest::binary>>
) do
erl_from_timestamp("#{date} #{hour}:#{minute}:00 #{rest}")
to_datetime("#{date} #{hour}:#{minute}:00 #{rest}")
end

def erl_from_timestamp(
# Fixes invalid value: Wed, 14 10 2015 12:34:17
def to_datetime(
<<date::binary-size(2), " ", month_digits::binary-size(2), " ", year::binary-size(4), " ",
hour::binary-size(2), ":", minute::binary-size(2), ":", second::binary-size(2),
rest::binary>>
) do
month_name = get_month_name(month_digits)
to_datetime("#{date} #{month_name} #{year} #{hour}:#{minute}:#{second}#{rest}")
end

def to_datetime(
<<date::binary-size(2), " ", month::binary-size(3), " ", year::binary-size(4), " ",
hour::binary-size(2), ":", minute::binary-size(2), ":", second::binary-size(2), " ",
_timezone::binary-size(5), _rest::binary>>
time_zone::binary>>
) do
year = year |> String.to_integer()
month_name = String.downcase(month)
month = Enum.find_index(@months, &(&1 == month_name)) + 1
month = get_month(String.downcase(month))
date = date |> String.to_integer()

hour = hour |> String.to_integer()
minute = minute |> String.to_integer()
second = second |> String.to_integer()

{{year, month, date}, {hour, minute, second}}
end
time_zone = parse_time_zone(time_zone)

def erl_from_timestamp(
<<date::binary-size(2), " ", month::binary-size(3), " ", year::binary-size(4), " ",
hour::binary-size(2), ":", minute::binary-size(2), ":", second::binary-size(2)>>
) do
erl_from_timestamp("#{date} #{month} #{year} #{hour}:#{minute}:#{second} (+00:00)")
date_string =
"#{year}-#{date_pad(month)}-#{date_pad(date)}T#{date_pad(hour)}:#{date_pad(minute)}:#{date_pad(second)}#{time_zone}"

{:ok, datetime, _offset} = DateTime.from_iso8601(date_string)
datetime
end

# This adds support for a now obsolete format
# https://tools.ietf.org/html/rfc2822#section-4.3
def erl_from_timestamp(
def to_datetime(
<<date::binary-size(2), " ", month::binary-size(3), " ", year::binary-size(4), " ",
hour::binary-size(2), ":", minute::binary-size(2), ":", second::binary-size(2), " ",
timezone::binary-size(3), _rest::binary>>
) do
erl_from_timestamp("#{date} #{month} #{year} #{hour}:#{minute}:#{second} (#{timezone})")
end

# This adds support for a now obsolete format (with obsolete timezone, UT)
# https://tools.ietf.org/html/rfc2822#section-4.3
def erl_from_timestamp(
<<date::binary-size(2), " ", month::binary-size(3), " ", year::binary-size(4), " ",
hour::binary-size(2), ":", minute::binary-size(2), ":", second::binary-size(2), " ",
"UT", _rest::binary>>
) do
erl_from_timestamp("#{date} #{month} #{year} #{hour}:#{minute}:#{second} (+00:00)")
to_datetime("#{date} #{month} #{year} #{hour}:#{minute}:#{second} (#{timezone})")
end

# Fixes invalid value: Tue Aug 8 12:05:31 CAT 2017
def erl_from_timestamp(
def to_datetime(
<<_day::binary-size(3), " ", month::binary-size(3), " ", date::binary-size(2), " ",
hour::binary-size(2), ":", minute::binary-size(2), ":", second::binary-size(2), " ",
_tz::binary-size(3), " ", year::binary-size(4), _rest::binary>>
) do
erl_from_timestamp("#{date} #{month} #{year} #{hour}:#{minute}:#{second} (+00:00)")
to_datetime("#{date} #{month} #{year} #{hour}:#{minute}:#{second}")
end

# Fixes invalid value with milliseconds Tue, 20 Jun 2017 09:44:58.568 +0000 (UTC)
def erl_from_timestamp(
def to_datetime(
<<date::binary-size(2), " ", month::binary-size(3), " ", year::binary-size(4), " ",
hour::binary-size(2), ":", minute::binary-size(2), ":", second::binary-size(2), ".",
_milliseconds::binary-size(3), rest::binary>>
) do
erl_from_timestamp("#{date} #{month} #{year} #{hour}:#{minute}:#{second}#{rest}}")
to_datetime("#{date} #{month} #{year} #{hour}:#{minute}:#{second}#{rest}}")
end

# Fixes invalid value: Tue May 30 15:29:15 2017
def erl_from_timestamp(
def to_datetime(
<<_day::binary-size(3), " ", month::binary-size(3), " ", date::binary-size(2), " ",
hour::binary-size(2), ":", minute::binary-size(2), ":", second::binary-size(2), " ",
year::binary-size(4), _rest::binary>>
) do
erl_from_timestamp("#{date} #{month} #{year} #{hour}:#{minute}:#{second} (+00:00)")
to_datetime("#{date} #{month} #{year} #{hour}:#{minute}:#{second} +0000")
end

# Fixes invalid value: Tue Aug 8 12:05:31 2017
def erl_from_timestamp(
def to_datetime(
<<_day::binary-size(3), " ", month::binary-size(3), " ", date::binary-size(1), " ",
hour::binary-size(2), ":", minute::binary-size(2), ":", second::binary-size(2), " ",
year::binary-size(4), _rest::binary>>
) do
erl_from_timestamp("#{date} #{month} #{year} #{hour}:#{minute}:#{second} (+00:00)")
to_datetime("#{date} #{month} #{year} #{hour}:#{minute}:#{second} +0000")
end

# Fixes invalid value: Wed, 14 10 2015 12:34:17
def erl_from_timestamp(
<<date::binary-size(2), " ", month_digits::binary-size(2), " ", year::binary-size(4), " ",
hour::binary-size(2), ":", minute::binary-size(2), ":", second::binary-size(2),
rest::binary>>
# Fixes missing time zone
def to_datetime(
<<date::binary-size(2), " ", month::binary-size(3), " ", year::binary-size(4), " ",
hour::binary-size(2), ":", minute::binary-size(2), ":", second::binary-size(2)>>
) do
month_name = get_month_name(month_digits)
erl_from_timestamp("#{date} #{month_name} #{year} #{hour}:#{minute}:#{second}#{rest}")
to_datetime("#{date} #{month} #{year} #{hour}:#{minute}:#{second} +0000")
end

# # Fixes invalid value: Wed, 14 10 2015 12:34:17
# def to_datetime(
# <<date::binary-size(2), " ", month_digits::binary-size(2), " ", year::binary-size(4), " ",
# hour::binary-size(2), ":", minute::binary-size(2), ":", second::binary-size(2),
# rest::binary>>
# ) do
# month_name = get_month_name(month_digits)
# to_datetime("#{date} #{month_name} #{year} #{hour}:#{minute}:#{second}#{rest}")
# end

defp to_four_digit_year(year) when year >= 0 and year < 50, do: 2000 + year
defp to_four_digit_year(year) when year < 100 and year >= 50, do: 1900 + year

defp date_pad(number) when number < 10, do: "0" <> Integer.to_string(number)
defp date_pad(number), do: Integer.to_string(number)

defp parse_time_zone(<<"(", time_zone::binary>>) do
time_zone
|> String.trim_trailing(")")
|> parse_time_zone()
end

@months
|> Enum.with_index(1)
|> Enum.each(fn {month_name, month_number} ->
defp get_month(unquote(month_name)), do: unquote(month_number)

defp get_month_name(unquote(String.pad_leading(to_string(month_number), 2, "0"))),
do: unquote(month_name)
end)

# Greenwich Mean Time
defp parse_time_zone("GMT"), do: "+0000"
# Universal Time
defp parse_time_zone("UTC"), do: "+0000"
defp parse_time_zone("UT"), do: "+0000"

# US
defp parse_time_zone("EDT"), do: "-0400"
defp parse_time_zone("EST"), do: "-0500"
defp parse_time_zone("CDT"), do: "-0500"
defp parse_time_zone("CST"), do: "-0600"
defp parse_time_zone("MDT"), do: "-0600"
defp parse_time_zone("MST"), do: "-0700"
defp parse_time_zone("PDT"), do: "-0700"
defp parse_time_zone("PST"), do: "-0800"
# Military A-Z
defp parse_time_zone(<<_zone_letter::binary-size(1)>>), do: "-0000"

defp parse_time_zone(<<"+", offset::binary-size(4), _rest::binary>>), do: "+#{offset}"
defp parse_time_zone(<<"-", offset::binary-size(4), _rest::binary>>), do: "-#{offset}"

defp parse_time_zone(time_zone) do
time_zone
|> String.trim_leading("(")
|> String.trim_trailing(")")
end

@doc """
Expand Down Expand Up @@ -194,13 +252,6 @@ defmodule Mail.Parsers.RFC2822 do
end)
end

@months
|> Enum.with_index(1)
|> Enum.each(fn {month_name, number} ->
defp get_month_name(unquote(String.pad_leading(to_string(number), 2, "0"))),
do: unquote(month_name)
end)

defp parse_headers(message, []), do: message

defp parse_headers(message, [header | tail]) do
Expand Down Expand Up @@ -250,7 +301,7 @@ defmodule Mail.Parsers.RFC2822 do
|> List.first()

defp parse_header_value("Date", timestamp),
do: erl_from_timestamp(timestamp)
do: to_datetime(timestamp)

defp parse_header_value("Received", value),
do: parse_received_value(value)
Expand Down Expand Up @@ -350,7 +401,7 @@ defmodule Mail.Parsers.RFC2822 do
{date, comment} -> {"#{value} #{comment}", date}
end

[value, {"date", erl_from_timestamp(remove_excess_whitespace(date))}]
[value, {"date", to_datetime(remove_excess_whitespace(date))}]

value ->
value
Expand Down
Loading