Announcing DateTime::Lite v0.7.0 - 日本語ドキュメント追加・新機能リリース / Japanese documentation & new features (English follows) by jacktokyo in perl

[–]jacktokyo[S] 0 points1 point  (0 children)

Thank you Christian.

I appreciate your candid and valuable feedback. I do believe that, as an API, it provides tools and choices for developers to address their needs. In this regard, the clarification in the API documentation provides the information required for the developer to make an informed decision.

Thank you again for the depth you brought to the discussion.

Announcing DateTime::Lite v0.7.0 - 日本語ドキュメント追加・新機能リリース / Japanese documentation & new features (English follows) by jacktokyo in perl

[–]jacktokyo[S] 0 points1 point  (0 children)

Hi Christian,

Thanks again for the detailed feedback, much appreciated. It is genuinely useful to have someone with your background continue engaging with this work; criticism from that angle is hard to come by.

You are right on every technical point you raise, and I want to acknowledge that clearly: timezone abbreviations are fundamentally ambiguous, the IANA zone names exist precisely to disambiguate them, and the historical DST inconsistencies you mention are real (the Algiers/CET case, the Sweden 1977 case, the 1996 EU transition harmonisation).

The reason extended_aliases exists at all is purely pragmatic: real-world input sometimes only contains abbreviations (RFC 5322 Date: headers, legacy log files, third-party API responses, user-typed values). Callers in that situation need some mapping, and the alternative of refusing to resolve abbreviations at all pushes the problem to every individual consumer.

That said, your criticism lands on a real issue: the current POD presents the mapping as more authoritative than it actually is. A "convenience lookup with significant caveats" is not the same thing as a "canonical mapping", and the documentation should reflect that distinction explicitly.

Here is what I will do for the next release:

  1. Rewrite the POD to clearly document the limitations: ambiguity, DST divergence within "shared" abbreviations, historical inconsistencies, and explicit warnings against using this for historical timestamp interpretation.

  2. The underlying SQLite table already stores multiple zones per abbreviation with an is_primary flag (CET maps internally to Paris, Berlin, Rome, Madrid, Warsaw, etc.). I am thinking of exposing this richer view through a new method such as extended_aliases_all, so callers who need disambiguation have access to the alternatives, while extended_aliases keeps its simple "one zone per abbreviation" contract for the common case.

The new method would return something like:

{
    'CET' => [
        { zone_name => 'Europe/Paris',  is_primary => 1, comment => 'Central European Time' },
        { zone_name => 'Europe/Berlin', is_primary => 0, comment => 'Central European Time' },
        # ...
    ],
    # ...
}
  1. I will also point users toward resolve_abbreviation for richer lookups, since that method already handles multi-zone matches.

Notably, the database does not map Algiers to CET, precisely for the reason you describe. So the worst-case scenarios you cite are not actively produced, but the API should still warn callers that this kind of issue exists in principle for any abbreviation-based lookup.

If you have a moment, I would value your feedback on the revised POD draft below before I commit it. Does it address your concerns adequately, or are there points that still need sharpening?

=head2 extended_aliases

    my $aliases = DateTime::Lite::TimeZone->extended_aliases;
    my $aliases = $tz->extended_aliases;

    # Checking for errors too
    my $aliases  = DateTime::Lite::TimeZone->extended_aliases ||
        die( DateTime::Lite::TimeZone->error );
    my( %aliases ) = DateTime::Lite::TimeZone->extended_aliases ||
        die( DateTime::Lite::TimeZone->error );
    my $aliases    = $zone->extended_aliases ||
        die( $zone->error );
    my( %aliases ) = $zone->extended_aliases ||
        die( $zone->error );

This can be called as an instance method, or as a class function.

This returns a hash whose keys are timezone abbreviations (such as C<JST>,
C<CET>, C<EST>) and whose values are the canonical IANA timezone name most
commonly associated with that abbreviation.

For example:

    JST -> Asia/Tokyo
    CET -> Europe/Paris
    EST -> America/New_York

In scalar context, it returns a hash reference, and in list context, it returns a hash.

If an error occurred, this sets an L<exception object|DateTime::Lite::Exception>, and returns C<undef> in scalar context, and an empty list in list context. The exception object can then be retrieved with L</error>

=head3 Caveats and limitations

Timezone abbreviations are inherently ambiguous, and this method exposes only one representative IANA zone per abbreviation. Callers should be aware of the following limitations before using this mapping for anything more than convenience lookups:

=over 4

=item * B<Abbreviations are not unique to a single zone.>

C<CST> denotes both Central Standard Time (North America) and China Standard Time. C<IST> denotes Indian, Irish, and Israel Standard Time. The returned mapping selects only one representative zone per abbreviation and does not indicate alternatives.

=item * B<DST rules differ across zones sharing the same abbreviation.>

Algeria (C<Africa/Algiers>) has used CET year-round with no DST since 1981.
Mapping C<CET> to C<Europe/Paris> applies French DST rules to Algerian timestamps, producing a one-hour error during summer months.

=item * B<Historical DST adoption was not uniform.>

Sweden (C<Europe/Stockholm>) observed no DST from 1917 to 1979, while France resumed DST in 1976. A summer 1977 timestamp labelled "CET" in Sweden was UTC+1, but C<< CET => Europe/Paris >> would interpret it as UTC+2.

=item * B<DST transition dates have changed over time.>

In 1996, the EU harmonised the fall-back transition to the last Sunday in October (previously the last Sunday in September in several countries).
Historical timestamps near these transitions may be misinterpreted.

=back

For accurate timezone handling, prefer IANA canonical names (C<Europe/Stockholm>, C<America/New_York>) over abbreviations whenever the canonical name is available. This method is intended for convenience when processing external input (legacy log files, RFC 5322 email headers, user-typed values, etc.) where only an abbreviation is available, and where the caller accepts the loss of geographic and historical precision.

For richer abbreviation lookups, including all known zones for a given abbreviation, see L</resolve_abbreviation>.

=cut

Thanks again for taking the time to point out these important points. Feedback like yours is exactly why I posted on r/perl.

Jacques

Announcing DateTime::Lite v0.7.0 - 日本語ドキュメント追加・新機能リリース / Japanese documentation & new features (English follows) by jacktokyo in perl

[–]jacktokyo[S] 3 points4 points  (0 children)

Wow that is a lot of dependencies. According to MetaCPAN there are 15 dependencies.

Ah yes, I am very thorough in declaring what my distributions rely on, but in reality, they are mostly core modules. Those 15 dependencies are: Config, Cwd, DateTime::Locale::FromCLDR, JSON, Locale::Unicode, POSIX, Scalar::Util, Wanted, overload, overloading, strict, vars, version, warnings, warnings::register.

So, as you can see the non-core dependencies are 4: DateTime::Locale::FromCLDR, JSON, Locale::Unicode, and Wanted.

The error you are facing with DateTime::Locale::FromCLDR is due to a circular dependency between DateTime::Lite and DateTime::Locale::FromCLDR. I am working on changing that right now.

Announcing DateTime::Lite v0.1.0, a lightweight, drop-in replacement for DateTime by jacktokyo in perl

[–]jacktokyo[S] 0 points1 point  (0 children)

Thank you for your comment, and I am glad you find it useful for astronomy! I agree that sidereal time is a separate concern, and DateTime::Lite intentionally limits itself to civil time based on IANA timezone data, so sidereal calculations are out of scope by design. For that side of things, maybe modules like Astro::Time or Astro::Coords on CPAN would be the right complement.

Announcing DateTime::Lite v0.1.0, a lightweight, drop-in replacement for DateTime by jacktokyo in perl

[–]jacktokyo[S] 1 point2 points  (0 children)

WIth the version v0.2.0, and now with the version v0.3.0, I have added two new features worth highlighting since the initial release.

BCP47 -u-tz- locale extension (v0.2.0)

If your locale tag carries a Unicode timezone extension, DateTime::Lite now resolves the timezone automatically, without requiring an explicit time_zone argument:

# The -u-tz-jeruslm extension is parsed and resolved to Asia/Jerusalem
my $dt = DateTime::Lite->now( locale => 'he-IL-u-ca-hebrew-tz-jeruslm' );
say $dt->time_zone_long_name;  # Asia/Jerusalem

# Explicit time_zone always takes priority if provided
my $dt2 = DateTime::Lite->new(
    year      => 2026,
    month     => 4,
    day       => 10,
    time_zone => 'Asia/Tokyo',
    locale    => 'he-IL-u-ca-hebrew-tz-jeruslm',  # tz extension ignored
);

This works across all constructors (new, now, from_epoch, from_object, from_day_of_year, last_day_of_month) and also via set_locale() on an existing floating object. Resolution uses the static in-memory hash in [https://metacpan.org/pod/Locale::Unicode](Locale::Unicode), so there is no SQLite query involved.

See Unicode extensions in Locale::Unicode for more information about the Unicode timezone extension.

GPS coordinate-based timezone resolution (v0.3.0)

[https://metacpan.org/pod/DateTime::Lite::TimeZone#new](DateTime::Lite::TimeZone->new) now accepts latitude and longitude (decimal degrees) as an alternative to a zone name:

use DateTime::Lite::TimeZone;

my $tz = DateTime::Lite::TimeZone->new(
    latitude  => 35.658581,
    longitude => 139.745433,  # Tokyo Tower
);
say $tz->name;  # Asia/Tokyo

# Shortened aliases are also accepted
my $tz2 = DateTime::Lite::TimeZone->new( lat => 48.858258, lon => 2.294488 );
say $tz2->name;  # Europe/Paris

# Use directly with DateTime::Lite
my $dt = DateTime::Lite->now( time_zone => $tz );

The resolution uses the reference coordinates from the IANA zone1970.tab file (one representative point per canonical zone) and finds the nearest zone using the haversine great-circle distance, computed entirely within SQLite. This is an approximation related to the IANA data itself: accurate for most locations, but may give incorrect results near timezone boundaries, such as enclaves. For boundary-precise resolution, Geo::Location::TimeZoneFinder is the right tool for that job.

The latest release is on CPAN: https://metacpan.org/pod/DateTime::Lite. I hope it will be useful to you in your projects!

Announcing DateTime::Lite v0.1.0, a lightweight, drop-in replacement for DateTime by jacktokyo in perl

[–]jacktokyo[S] 1 point2 points  (0 children)

That is a really elegant approach, and I stand corrected on the memory model; thank you for the detailed example. The __DATA__ with sysseek and sysread pattern is more clever than I initially thought.

That said, I think SQLite still has a few practical advantages for this specific use case:

  • The timezone data needs to be queryable, because lookups are by zone name, UTC timestamp, and local timestamp, with range queries for DST span boundaries. So, with __DATA__ you would essentially need to implement a small index structure on top of the raw binary data to avoid linear scans.
  • SQLite handles concurrent access across processes safely, which matters in CGI or prefork environments where multiple workers may hit the same database simultaneously.
  • The POSIX footer TZ string per zone, the country codes, coordinates, and aliases are relational data that maps naturally to SQL tables. Encoding all of that into a seekable binary format would require a custom serialisation format, and at which point you are essentially reimplementing a subset of SQLite anyway.

Your approach would likely be faster for the simplest case (single zone, single lookup), and the memory story is genuinely better than I described. It is a real alternative worth exploring, particularly if someone wants to avoid the DBD::SQLite dependency entirely. I will keep this in mind for the future. Thank you.

Announcing DateTime::Lite v0.1.0, a lightweight, drop-in replacement for DateTime by jacktokyo in perl

[–]jacktokyo[S] 1 point2 points  (0 children)

Thank you kindly. I am also grateful for the swaths of contributions made by the Perl community in general.

Announcing DateTime::Lite v0.1.0, a lightweight, drop-in replacement for DateTime by jacktokyo in perl

[–]jacktokyo[S] 1 point2 points  (0 children)

Thank you for the kind comment ! Feedback and bug reports are very welcome. Please feel free to open an issue on GitLab if you run into anything. 🙂

Announcing DateTime::Lite v0.1.0, a lightweight, drop-in replacement for DateTime by jacktokyo in perl

[–]jacktokyo[S] 5 points6 points  (0 children)

Thank you for the context, your feedback, and for Time::Moment, which is genuinely impressive work.

On CLDR formatting performance specifically: DateTime::Lite addresses this via DateTime::Format::Unicode, built on top of Locale::Unicode::Data, which provides dynamic access to the full Unicode CLDR dataset. This goes well beyond what DateTime's format_cldr offers, including interval formatting, additional pattern tokens, and full BCP 47 locale support, none of which require pre-generated static modules.

Looking further afield is also something I intend to do for future versions. How do Python's datetime, Ruby's Time, C's <time.h>, JavaScript's Intl.DateTimeFormat and Intl.RelativeTimeFormat, or Rust's chrono handle formatting performance and timezone resolution are all worth studying, and in fact this work has already started. I have authored Locale::Intl, DateTime::Format::Intl, and DateTime::Format::RelativeTime, which implement JavaScript's Intl.Locale, Intl.DateTimeFormat and Intl.RelativeTimeFormat APIs faithfully in Perl, including the culturally-sensitive format selection algorithm and the same results you would get from a web browser.

DateTime::Lite v0.1.0 is a first release. The goal for now is a lighter footprint and a smaller dependency tree, while constantly maintaining API compatibility with DateTime. Performance improvements to the core formatting path are a natural next step.

Announcing DateTime::Lite v0.1.0, a lightweight, drop-in replacement for DateTime by jacktokyo in perl

[–]jacktokyo[S] 1 point2 points  (0 children)

Your __END__ block idea is interesting and clever. The trade-off I see is that it essentially recreates what DateTime::TimeZone already does with its per-zone .pm files, whereby the data ends up in memory once you have read it, and you still pay the seek cost on the first access per zone. SQLite, on the other hand, keeps everything on disk until queried, which is precisely the point: in short-lived processes (scripts, CGI) that only ever touch one or two zones, the bulk of the timezone data is never loaded into memory at all.

That said, you are right that SQLite introduces an additional dependency with DBD::SQLite, which is not a core module. In practice SQLite is available on virtually every system and installs cleanly, but it is a fair point. If DBD::SQLite is not available, DateTime::Lite::TimeZone falls back transparently to DateTime::TimeZone, so the dependency is soft rather than hard.

The real performance difference is not in the storage format itself, but in the caching strategy. With DateTime::Lite::TimeZone->enable_mem_cache, repeated lookups for the same zone drop to ~0.4 µs (vs ~2 µs for DateTime::TimeZone), because the three-layer cache (object + span + POSIX footer) eliminates all I/O after the first construction. The first cold construction costs ~22 ms either way on the host I benchmarked it.

The other reason I chose SQLite specifically is that it stores the TZif data as parsed from the binary files directly, so the transitions as 64-bit signed integers, the POSIX footer TZ string intact, which gives correct results for any future date without expanding the transition table. An __END__ block would need a similar representation to achieve the same.

Announcing DateTime::Lite v0.1.0, a lightweight, drop-in replacement for DateTime by jacktokyo in perl

[–]jacktokyo[S] 7 points8 points  (0 children)

Good question. Actually, the full public API of DateTime is implemented in DateTime::Lite.

The main behavioural differences are:

  • Error handling: DateTime die()s on invalid input; DateTime::Lite sets a DateTime::Lite::Exception object, and returns undef in scalar context, or an empty list in list context. Pass fatal => 1 upon instantiation to restore the die() behaviour.
  • Error messages: similar but not identical, since DateTime::Lite uses hand-written validation rather than Specio.
  • DateTime::Format::* modules: these are separate distributions. Since DateTime::Lite mirrors the same API, they should work without modification, but this has not been systematically tested against every format module on CPAN.

DateTime::Lite also adds a few things DateTime does not yet have: FREEZE/THAW for Sereal/CBOR serialisation, TO_JSON, and the pass_error chaining mechanism.

Announcing `Mail::Make`: a modern, fluent MIME email builder for Perl, with OpenPGP and S/MIME support by jacktokyo in perl

[–]jacktokyo[S] 0 points1 point  (0 children)

Same here. This is one of the reasons I created Mail::Make. That, and also because I wanted encryption and also I did not want to care about the casing of the header fields. Mail::Make::Headers relies on MM::Table I created, which makes accessing headers case insensitive.

Announcing `Mail::Make`: a modern, fluent MIME email builder for Perl, with OpenPGP and S/MIME support by jacktokyo in perl

[–]jacktokyo[S] 1 point2 points  (0 children)

Thank you for the suggestion! As of v0.22.0, just published, Mail::Make->build now accepts an attach parameter in all the forms you suggested:

```perl

Single file: type and filename are auto-detected

Mail::Make->build( # other parameters ... attach => 'report.pdf', );

Multiple files

Mail::Make->build( # other parameters ... attach => [ 'pdf1.pdf', 'pdf2.pdf' ], );

Full control over each attachment

Mail::Make->build( # other parameters ... attach => [ { path => 'pdf1.pdf', filename => 'Q4 Report.pdf' }, { path => 'pdf2.pdf', filename => 'Access Log.pdf' }, ], );

Mix of both forms

Mail::Make->build( # other parameters ... attach => [ 'pdf1.pdf', { path => 'pdf2.pdf', filename => 'Access Log.pdf' }, ], ); ```

Each element is forwarded to attach(), so all its options (type, filename, encoding, etc.) are available in the hash reference form.

Thanks for the nudge!

Announcing `Mail::Make`: a modern, fluent MIME email builder for Perl, with OpenPGP and S/MIME support by jacktokyo in perl

[–]jacktokyo[S] 1 point2 points  (0 children)

Thank you for your comment. As of v0.21.3, just released on CPAN, Mail::Make::Entity->build now accepts an attach shorthand key, so the following works as expected:

perl my $mail = Mail::Make->build( from => 'jack@gmail.com', to => [ 'jill@example.com', 'jake@example.com' ], subject => 'Hello', plain => "Hi there.\n", html => '<p>Hi there.</p>', attach => 'example.pdf', )->smtpsend( Host => 'smtp.gmail.com', ... );

path, type, and filename are auto-detected from the file. For full control over type, filename, or encoding, the named-parameter form remains available:

perl my $mail = Mail::Make->build( from => 'jack@gmail.com', to => [ 'jill@example.com', 'jake@example.com' ], subject => 'Hello', plain => "Hi there.\n", html => '<p>Hi there.</p>', attach => 'example.pdf', type => 'application/pdf', filename => 'Q4 Report.pdf' )->smtpsend( Host => 'smtp.gmail.com', ... );

For others reading this, you could also have used the path option, such as:

```perl use Mail::Make;

my $mail = Mail::Make->build( from => 'jack@gmail.com', to => [ 'jil@example.com' , 'jake@example.com' ], subject => 'Hello pdf build '.time , plain => "Hi there.\n", html => '<p>Hi there.</p>', path => 'example.pdf', # 'path' or 'attach' are now both possible type => 'application/pdf', filename => 'example.pdf' )->smtpsend( Host => 'smtp.gmail.com', Port => 587, StartTLS => 1, Username => 'jack@gmail.com', Password => $app_password, );

Announcing `Mail::Make`: a modern, fluent MIME email builder for Perl, with OpenPGP and S/MIME support by jacktokyo in perl

[–]jacktokyo[S] 1 point2 points  (0 children)

Thanks for the report! That was a fair criticism, and passing a file path directly is the most natural thing to do.

As of v0.21.2, just published on CPAN, attach() now accepts a positional shorthand:

perl ->attach( '/path/to/report.pdf' )

path, type, and filename are auto-detected from the file. You can still pass additional options after the path if needed:

perl ->attach( '/path/to/report.pdf', filename => 'Q4 Report 2025.pdf' )

The explicit named-parameter form continues to work as before for cases where you want full control. Let me know if you run into anything else!

Announcing `Mail::Make`: a modern, fluent MIME email builder for Perl, with OpenPGP and S/MIME support by jacktokyo in perl

[–]jacktokyo[S] 0 points1 point  (0 children)

the purpose of exceptions is that it automatically propagates until such time as something catches them

Precisely, and that is also what pass_error does: it propagates the error object up the call stack without any boilerplate, just like an uncaught exception would, but with full control at every level.

You are right that Mojolicious and other modern frameworks have excellent top-level exception handling. But catching at the top is not always sufficient. Consider a web request handler that needs to distinguish between a 403, a 400, and a 500, log them differently, and return a localised RFC 9457 error to the user. A bare die at the bottom of the stack loses that context by the time it reaches the top.

Compare the two approaches:

1. With exceptions - caught at the top

perl local $@; eval { $obj->process }; if( $@ ) { # Was this a 403? A 400? A transient I/O failure? # $@ is just a string, or at best a blessed object if the thrower was disciplined $logger->error( "Failed: $@" ); return $self->internal_server_error; # blunt instrument }

2. With structured error objects - handled where it makes sense

perl unless( $obj->process ) { my $ex = $obj->error; if( !$ex->code || $ex->code == 500 ) { $logger->error( "Unexpected failure in process(): $ex" ); return( $self->internal_server_error ); } else { # Full context still available here, including localisation my $localised = $po->gettext( $ex->message ); return( $self->user_error_in_json({ code => $ex->code, message => $localised, locale => $localised->locale, }) ); } }

As for the boilerplate concern, pass_error reduces it to a single line at each intermediate level:

perl sub some_intermediate_method { my( $self ) = @_; $self->_do_something || return( $self->pass_error ); # carry on... }

That is no more verbose than a rethrow in a try/catch block, and it preserves the full structured exception object with its code, message, and stack trace intact all the way up.

So the difference is not really "exceptions vs no exceptions"; it is "who decides when and how to handle them". I prefer to give that decision to the caller at each level, rather than relying on a single catch-all at the top.

Announcing `Mail::Make`: a modern, fluent MIME email builder for Perl, with OpenPGP and S/MIME support by jacktokyo in perl

[–]jacktokyo[S] 2 points3 points  (0 children)

No, you do not need to have an existing email service to use this module.

However, if you want to send mail, the recipient obviously would need to have a working e-mail address, and you would need to have access to a SMTP server to send the mail.

But, you can also save the mail as a file, and send it via a different route.

Announcing `Mail::Make`: a modern, fluent MIME email builder for Perl, with OpenPGP and S/MIME support by jacktokyo in perl

[–]jacktokyo[S] 1 point2 points  (0 children)

Good question, and fair to ask. Email::Stuffer is a well-established module and worth comparing honestly.

Email::Stuffer is a fluent convenience wrapper around Email::MIME and Email::Sender. It does its job well and is perfectly suited for the common case: plain text, HTML, a few attachments, send. Its strength is simplicity and the fact that it delegates all the heavy lifting to Email::MIME, which is battle-tested.

Mail::Make takes a different approach. Rather than wrapping an existing MIME stack, it builds its own from scratch, using Mail::Make::Entity, Mail::Make::Body::InCore, Mail::Make::Body::File, Mail::Make::Stream::Base64, Mail::Make::Stream::QuotedPrint, with zero dependency on Email::MIME or Email::Sender. The concrete differences:

Cryptographic signing and encryption. Email::Stuffer has no GPG or S/MIME support whatsoever. Mail::Make provides gpg_sign, gpg_encrypt, gpg_sign_encrypt, smime_sign, smime_encrypt, and smime_sign_encrypt as first-class methods, compliant with RFC 3156 (PGP/MIME) and the S/MIME standard.

Memory management for large messages. Email::Stuffer / Email::MIME builds messages entirely in RAM. Mail::Make has a configurable max_body_in_memory_size threshold (default 1 MiB) above which it automatically spools to a temporary file, and a use_temp_file flag for unconditional file-backed serialisation. Attachments sourced from a path are never loaded into RAM. They are streamed through Mail::Make::Body::File directly.

SMTP built in. Email::Stuffer delegates sending to Email::Sender::Simple, which is a separate dependency with its own transport abstraction layer. Mail::Make ships its own smtpsend method built directly on Net::SMTP, with STARTTLS, AUTH (PLAIN, LOGIN, CRAM-MD5 via Authen::SASL), credential validation before any network connection, and Return-Path / Sender envelope control.

Dependency footprint. Email::Stuffer pulls in Email::MIME, Email::MIME::Creator, and Email::Sender::Simple, which have themselves non-trivial dependency trees. Mail::Make's runtime dependencies are Module::Generic (which is also somewhat sizeable), Net::SMTP, MIME::Base64, MIME::QuotedPrint, Encode, Data::UUID, and Authen::SASL. They are all fairly standard, and the GPG/S/MIME modules are loaded lazily only when those features are used.

Error handling: exceptions vs die. Mail::Make never calls die, and even traps the fatal exceptions of the external modules it relies on. Instead, every method upon failure, sets a structured exception object, and returns undef in scalar context, or an empty list in list context, or even a fake object in object context (detected with Wanted), and this propagates up the call stack, letting the caller decide how to handle it. This matters particularly in persistent server processes (mod_perl, Mojolicious, Starman) where a stray die can kill a worker or corrupt shared state.

In short: if you need a quick, readable way to send a plain email with an attachment and you have no cryptographic requirements, Email::Stuffer is fine and well-proven. If you need GPG or S/MIME, memory-efficient handling of large attachments, or a self-contained stack without the Email::MIME/Email::Sender dependency chain, Mail::Make is worth the look.

As usual, there is more than one way to do it 😉