File Coverage

lib/Mail/Make/GPG.pm
Criterion Covered Total %
statement 24 258 9.3
branch 0 90 0.0
condition 0 93 0.0
subroutine 8 34 23.5
pod 10 12 83.3
total 42 487 8.6


line stmt bran cond sub pod time code
1             ##----------------------------------------------------------------------------
2             ## MIME Email Builder - ~/lib/Mail/Make/GPG.pm
3             ## Version v0.1.4
4             ## Copyright(c) 2026 DEGUEST Pte. Ltd.
5             ## Author: Jacques Deguest <jack@deguest.jp>
6             ## Created 2026/03/05
7             ## Modified 2026/03/05
8             ## All rights reserved.
9             ##
10             ## This program is free software; you can redistribute it and/or modify it
11             ## under the same terms as Perl itself.
12             ##----------------------------------------------------------------------------
13             package Mail::Make::GPG;
14             BEGIN
15             {
16 1     1   3622 use strict;
  1         2  
  1         29  
17 1     1   3 use warnings;
  1         2  
  1         46  
18 1     1   291 warnings::register_categories( 'Mail::Make' );
19 1     1   3 use parent qw( Module::Generic );
  1         1  
  1         5  
20 1     1   65 use vars qw( $VERSION $EXCEPTION_CLASS );
  1         1  
  1         45  
21 1     1   3 use Mail::Make::Exception;
  1         1  
  1         5  
22 1         2 our $EXCEPTION_CLASS = 'Mail::Make::Exception';
23 1         26 our $VERSION = 'v0.1.4';
24             };
25              
26 1     1   5 use strict;
  1         1  
  1         13  
27 1     1   3 use warnings;
  1         1  
  1         2919  
28              
29             sub init
30             {
31 0     0 1   my $self = shift( @_ );
32 0           $self->{auto_fetch} = 0; # bool: fetch missing recipient keys from keyserver
33 0           $self->{digest} = 'SHA256';
34 0           $self->{gpg_bin} = undef; # explicit path to gpg binary; undef = search PATH
35 0           $self->{key_id} = undef; # default signing key fingerprint or ID
36 0           $self->{keyserver} = undef; # keyserver URL for auto-fetch
37 0           $self->{passphrase} = undef; # string or CODE ref; undef = use gpg-agent
38 0           $self->{_exception_class} = $EXCEPTION_CLASS;
39 0 0         $self->SUPER::init( @_ ) || return( $self->pass_error );
40 0           return( $self );
41             }
42              
43 0     0 1   sub auto_fetch { return( shift->_set_get_boolean( 'auto_fetch', @_ ) ); }
44              
45 0     0 1   sub digest { return( shift->_set_get_scalar( 'digest', @_ ) ); }
46              
47             # encrypt( entity => $entity, recipients => \@addrs [, %opts] )
48             # Signs $entity and returns a new Mail::Make object whose top-level MIME type is
49             # multipart/encrypted per RFC 3156 §4.
50             #
51             # The caller is responsible for supplying recipient public keys in the GnuPG keyring.
52             # When AutoFetch + KeyServer are set, we attempt key retrieval first.
53             sub encrypt
54             {
55 0     0 1   my $self = shift( @_ );
56 0           my $opts = $self->_get_args_as_hash( @_ );
57             my $entity = $opts->{entity} ||
58 0   0       return( $self->error( 'encrypt(): entity option is required.' ) );
59             my $recipients = $opts->{recipients} ||
60 0   0       return( $self->error( 'encrypt(): recipients option is required.' ) );
61 0 0         $recipients = [ $recipients ] unless( ref( $recipients ) eq 'ARRAY' );
62 0 0         unless( scalar( @$recipients ) )
63             {
64 0           return( $self->error( 'encrypt(): recipients must not be empty.' ) );
65             }
66              
67 0 0         $self->_maybe_fetch_keys( $recipients ) || return( $self->pass_error );
68              
69             # Serialise the original message body for gpg input
70 0   0       my $plaintext = $self->_serialise_for_gpg( $entity ) || return( $self->pass_error );
71              
72 0           my @args = ( $self->_base_gpg_args, '--encrypt', '--armor' );
73 0           push( @args, '--recipient', $_ ) for( @{ $recipients } );
  0            
74              
75 0   0       my $ciphertext = $self->_run_gpg( \@args, \$plaintext ) || return( $self->pass_error );
76              
77 0           return( $self->_build_encrypted_mail( $entity, \$ciphertext ) );
78             }
79              
80 0     0 1   sub gpg_bin { return( shift->_set_get_scalar( 'gpg_bin', @_ ) ); }
81              
82 0     0 1   sub key_id { return( shift->_set_get_scalar( 'key_id', @_ ) ); }
83              
84 0     0 1   sub keyserver { return( shift->_set_get_scalar( 'keyserver', @_ ) ); }
85              
86 0     0 1   sub passphrase { return( shift->_set_get_scalar( 'passphrase', @_ ) ); }
87              
88             # sign( entity => $entity [, %opts] )
89             # Signs $entity and returns a new Mail::Make object whose top-level MIME type is
90             # multipart/signed per RFC 3156 §5.
91             sub sign
92             {
93 0     0 1   my $self = shift( @_ );
94 0           my $opts = $self->_get_args_as_hash( @_ );
95             my $entity = $opts->{entity} ||
96 0   0       return( $self->error( 'sign(): entity option is required.' ) );
97              
98 0   0       my $key_id = $self->_resolve_key_id( $opts ) ||
99             return( $self->error( 'sign(): KeyId is required (set via option or gpg_sign() default).' ) );
100 0   0       my $digest = uc( $opts->{digest} // $self->{digest} );
101              
102             # Ensure Date and Message-ID are committed to the Mail::Make object's own _headers
103             # BEFORE serialising. This must happen without calling as_entity(), which would merge
104             # RFC 2822 headers onto $self->{_parts}[0].
105 0 0         $self->_ensure_envelope_headers( $entity ) || return( $self->pass_error );
106              
107             # Serialise the MIME body that will be signed - Part 1 of multipart/signed.
108             # Per RFC 3156 §5.1 this is the entity with CRLF line endings, exactly as it will
109             # appear on the wire.
110 0   0       my $canonical = $self->_serialise_for_gpg( $entity ) || return( $self->pass_error );
111              
112 0           my $passphrase = $self->_resolve_passphrase( $opts );
113 0 0         return( $self->pass_error ) if( $self->error ); # CODE ref may have thrown
114              
115 0           my @args = (
116             $self->_base_gpg_args,
117             '--detach-sign',
118             '--armor',
119             '--digest-algo', $digest,
120             '--local-user', $key_id,
121             );
122 0 0         if( defined( $passphrase ) )
123             {
124 0           push( @args, '--passphrase-fd', '0', '--pinentry-mode', 'loopback' );
125             }
126              
127 0   0       my $signature = $self->_run_gpg( \@args, \$canonical, passphrase => $passphrase ) || return( $self->pass_error );
128              
129 0           return( $self->_build_signed_mail( $entity, \$signature, $canonical, digest => $digest ) );
130             }
131              
132             # sign_encrypt( entity => $entity, recipients => \@addrs [, %opts] )
133             # Signs then encrypts $entity. The result is a multipart/encrypted message whose payload
134             # is a signed+encrypted OpenPGP message.
135             sub sign_encrypt
136             {
137 0     0 1   my $self = shift( @_ );
138 0           my $opts = $self->_get_args_as_hash( @_ );
139 0   0       my $entity = $opts->{entity} || return( $self->error( 'sign_encrypt(): entity option is required.' ) );
140 0   0       my $recipients = $opts->{recipients} || return( $self->error( 'sign_encrypt(): recipients option is required.' ) );
141 0 0         $recipients = [ $recipients ] unless( ref( $recipients ) eq 'ARRAY' );
142 0 0         unless( scalar( @$recipients ) )
143             {
144 0           return( $self->error( 'sign_encrypt(): recipients must not be empty.' ) );
145             }
146              
147 0   0       my $key_id = $self->_resolve_key_id( $opts ) || return( $self->error( 'sign_encrypt(): KeyId is required.' ) );
148 0   0       my $digest = uc( $opts->{digest} // $self->{digest} );
149              
150 0 0         $self->_maybe_fetch_keys( $recipients ) || return( $self->pass_error );
151              
152 0   0       my $plaintext = $self->_serialise_for_gpg( $entity ) || return( $self->pass_error );
153              
154 0           my $passphrase = $self->_resolve_passphrase( $opts );
155 0 0         return( $self->pass_error ) if( $self->error );
156              
157 0           my @args = (
158             $self->_base_gpg_args,
159             '--sign',
160             '--encrypt',
161             '--armor',
162             '--digest-algo', $digest,
163             '--local-user', $key_id,
164             );
165 0           push( @args, '--recipient', $_ ) for( @{ $recipients } );
  0            
166 0 0         if( defined( $passphrase ) )
167             {
168 0           push( @args, '--passphrase-fd', '0', '--pinentry-mode', 'loopback' );
169             }
170              
171 0   0       my $ciphertext = $self->_run_gpg( \@args, \$plaintext, passphrase => $passphrase ) || return( $self->pass_error );
172              
173 0           return( $self->_build_encrypted_mail( $entity, \$ciphertext ) );
174             }
175              
176             # _base_gpg_args() → list
177             # Returns args common to every gpg invocation.
178             sub _base_gpg_args
179             {
180 0     0     my $self = shift( @_ );
181 0   0       my $bin = $self->_find_gpg_bin || return( $self->pass_error );
182             return(
183 0           $bin,
184             '--batch',
185             '--no-tty',
186             '--status-fd', '2',
187             );
188             }
189              
190             # _build_encrypted_mail( $original_mail, \$ciphertext ) → Mail::Make object
191             # Constructs a new Mail::Make object whose body is a RFC 3156 §4
192             # multipart/encrypted structure.
193             #
194             # Structure:
195             # multipart/encrypted; protocol="application/pgp-encrypted"
196             # ├── application/pgp-encrypted ("Version: 1")
197             # └── application/octet-stream (ASCII-armoured ciphertext)
198             sub _build_encrypted_mail
199             {
200 0     0     my( $self, $original, $ciphertext_ref ) = @_;
201 0           require Mail::Make;
202 0           require Mail::Make::Entity;
203              
204 0           my $boundary = _random_boundary();
205              
206             # Build the two MIME parts
207 0   0       my $ver_part = Mail::Make::Entity->build(
208             type => 'application/pgp-encrypted',
209             encoding => '7bit',
210             data => "Version: 1\r\n",
211             ) || return( $self->pass_error( Mail::Make::Entity->error ) );
212 0           $ver_part->headers->set( 'Content-Disposition' => 'inline' );
213              
214             my $ct_part = Mail::Make::Entity->build(
215             type => 'application/octet-stream',
216             encoding => '7bit',
217 0   0       data => ${ $ciphertext_ref },
218             ) || return( $self->pass_error( Mail::Make::Entity->error ) );
219 0           $ct_part->headers->set( 'Content-Disposition' => 'inline; filename="encrypted.asc"' );
220              
221             # Assemble the multipart/encrypted container
222 0   0       my $top = Mail::Make::Entity->build(
223             type => sprintf(
224             'multipart/encrypted; protocol="application/pgp-encrypted"; boundary="%s"',
225             $boundary
226             ),
227             ) || return( $self->pass_error( Mail::Make::Entity->error ) );
228 0           $top->add_part( $ver_part );
229 0           $top->add_part( $ct_part );
230              
231 0           return( $self->_wrap_in_mail( $original, $top ) );
232             }
233              
234             # _build_signed_mail( $original_mail, \$signature, digest => $algo ) → Mail::Make object
235             # Constructs a new Mail::Make object whose body is a RFC 3156 §5
236             # multipart/signed structure.
237             #
238             # Structure:
239             # multipart/signed; protocol="application/pgp-signature"; micalg="pgp-sha256"
240             # ├── <original MIME body - the part that was signed>
241             # └── application/pgp-signature (ASCII-armoured detached signature)
242             sub _build_signed_mail
243             {
244 0     0     my $self = shift( @_ );
245 0           my $original = shift( @_ );
246 0           my $signature_ref = shift( @_ );
247 0           my $canonical = shift( @_ );
248 0           my $opts = $self->_get_args_as_hash( @_ );
249 0   0       my $digest = lc( $opts->{digest} // $self->{digest} );
250 0           require Mail::Make;
251 0           require Mail::Make::Entity;
252              
253 0           my $boundary = _random_boundary();
254              
255             # Part 1: a fresh entity whose content is exactly $canonical (the MIME-only bytes
256             # that gpg signed). Built via _entity_from_canonical() which parses the Content-* headers
257             # from $canonical and wraps the body in a Body::InCore.
258             # We never call as_entity() on $original here: for simple text/plain messages
259             # as_entity() would re-add RFC 2822 headers onto $self->{_parts}[0], corrupting the
260             # MIME-only Part 1.
261 0   0       my $body_entity = $self->_entity_from_canonical( $canonical ) || return( $self->pass_error );
262              
263             # Part 2: the detached signature
264             my $sig_part = Mail::Make::Entity->build(
265             type => 'application/pgp-signature',
266             encoding => '7bit',
267 0   0       data => ${ $signature_ref },
268             ) || return( $self->pass_error( Mail::Make::Entity->error ) );
269 0           $sig_part->headers->set( 'Content-Disposition' => 'inline; filename="signature.asc"' );
270              
271             # Multipart/signed container
272 0   0       my $top = Mail::Make::Entity->build(
273             type => sprintf(
274             'multipart/signed; protocol="application/pgp-signature"; micalg="pgp-%s"; boundary="%s"',
275             $digest, $boundary
276             ),
277             ) || return( $self->pass_error( Mail::Make::Entity->error ) );
278 0           $top->add_part( $body_entity );
279 0           $top->add_part( $sig_part );
280              
281 0           return( $self->_wrap_in_mail( $original, $top ) );
282             }
283              
284             # _ensure_envelope_headers( $mail_make_obj )
285             # Generates Date and Message-ID on $mail directly into its _headers object WITHOUT calling
286             # as_entity(). Called by sign() and sign_encrypt() before _serialise_for_gpg() so that
287             # those values exist when _wrap_in_mail() later copies _headers onto the outer multipart wrapper.
288             sub _ensure_envelope_headers
289             {
290 0     0     my $self = shift( @_ );
291 0   0       my $mail = shift( @_ ) ||
292             return( $self->error( "No Make::Mail instance was provided." ) );
293 0 0         if( !$self->_is_a( $mail => 'Mail::Make' ) )
    0          
294             {
295 0           return( $self->error( "Value provided is not a Mail::Make instance." ) );
296             }
297             elsif( !$self->_is_a( $mail->{_headers} => 'Mail::Make::Headers' ) )
298             {
299 0           return( $self->error( "No Mail::Make::Headers instance could be found on Mail::Make object!" ) );
300             }
301              
302             # Date
303 0 0         unless( $mail->{_headers}->exists( 'Date' ) )
304             {
305             $mail->{_headers}->init_header( Date => $mail->_format_date ) ||
306 0 0         return( $self->pass_error( $mail->{_headers}->error ) );
307             }
308              
309             # Message-ID
310 0 0         unless( $mail->{_headers}->exists( 'Message-ID' ) )
311             {
312             $mail->{_headers}->message_id(
313             { generate => 1, domain => $mail->_default_domain }
314 0 0         ) || return( $self->pass_error( $mail->{_headers}->error ) );
315             }
316              
317 0           return(1);
318             }
319              
320             # _entity_from_canonical( $canonical ) → Mail::Make::Entity
321             # Builds a fresh Mail::Make::Entity whose headers and body match $canonical exactly (the
322             # MIME-only string returned by _serialise_for_gpg). Used as Part 1 of the multipart/signed
323             # wrapper so that what Thunderbird verifies is byte-for-byte identical to what gpg signed.
324             sub _entity_from_canonical
325             {
326 0     0     my( $self, $canonical ) = @_;
327 0           require Mail::Make::Entity;
328 0           require Mail::Make::Headers;
329 0           require Mail::Make::Body::InCore;
330              
331             # Split on the first CRLF+CRLF blank-line separator.
332 0           my $pos = index( $canonical, "\015\012\015\012" );
333 0 0         if( $pos < 0 )
334             {
335 0           return( $self->error( '_entity_from_canonical(): no header/body separator.' ) );
336             }
337              
338 0           my $hdr_block = substr( $canonical, 0, $pos );
339 0           my $body = substr( $canonical, $pos + 4 ); # skip CRLFCRLF
340              
341             # Build a fresh entity with a fresh Headers object.
342 0   0       my $entity = Mail::Make::Entity->new || return( $self->pass_error( Mail::Make::Entity->error ) );
343 0   0       my $headers = Mail::Make::Headers->new || return( $self->pass_error( Mail::Make::Headers->error ) );
344 0           $entity->headers( $headers );
345              
346             # Parse MIME header lines from $hdr_block.
347             # Continuation lines (starting with whitespace) are folded onto the preceding field value.
348 0           my $cur_name = '';
349 0           my $cur_value = '';
350 0           for my $line ( split( /\015\012/, $hdr_block ) )
351             {
352 0 0         if( $line =~ /^[ \t]/ )
    0          
353             {
354             # Continuation: append stripped content to current value.
355 0           ( my $cont = $line ) =~ s/^[ \t]+//;
356 0           $cur_value .= ' ' . $cont;
357             }
358             elsif( $line =~ /^([\x21-\x39\x3B-\x7E]+):\s*(.*?)\s*$/ )
359             {
360             # New field: flush the previous one first.
361 0 0         if( CORE::length( $cur_name ) )
362             {
363 0 0         $headers->push_header( $cur_name => $cur_value ) ||
364             return( $self->pass_error( $headers->error ) );
365             }
366 0           ( $cur_name, $cur_value ) = ( $1, $2 );
367             }
368             }
369             # Flush the last header.
370 0 0         if( CORE::length( $cur_name ) )
371             {
372 0 0         $headers->push_header( $cur_name => $cur_value ) ||
373             return( $self->pass_error( $headers->error ) );
374             }
375              
376             # Attach the body verbatim; mark is_encoded so print_body skips re-encoding (the body
377             # in $canonical is already encoded).
378 0   0       my $body_obj = Mail::Make::Body::InCore->new( $body ) ||
379             return( $self->pass_error( Mail::Make::Body::InCore->error ) );
380 0           $entity->body( $body_obj );
381 0           $entity->{is_encoded} = 1;
382              
383             # Cache effective_type so is_multipart() and similar checks work.
384 0   0       my $ct = $headers->get( 'Content-Type' ) // '';
385 0           ( my $type = $ct ) =~ s/;.*//s;
386 0           $type =~ s/\s+$//;
387 0           $entity->effective_type( $type );
388              
389 0           return( $entity );
390             }
391              
392             # _find_gpg_bin() → $path
393             # Locates the gpg binary: explicit gpg_bin attribute wins; otherwise we search for gpg2
394             # then gpg in PATH via File::Which.
395             sub _find_gpg_bin
396             {
397 0     0     my $self = shift( @_ );
398 0 0 0       if( defined( $self->{gpg_bin} ) && length( $self->{gpg_bin} ) )
399             {
400 0           return( $self->{gpg_bin} );
401             }
402              
403 0 0         $self->_load_class( 'File::Which' ) ||
404             return( $self->error( 'File::Which is required to locate gpg. Install it with: cpan File::Which' ) );
405              
406 0           for my $candidate ( qw( gpg2 gpg ) )
407             {
408 0           my $path = File::Which::which( $candidate );
409 0 0 0       if( defined( $path ) && length( $path ) )
410             {
411 0           $self->{gpg_bin} = $path;
412 0           return( $path );
413             }
414             }
415 0           return( $self->error( 'gpg binary not found in PATH. Install GnuPG or set the GpgBin option.' ) );
416             }
417              
418             # _maybe_fetch_keys( \@recipients )
419             # When auto_fetch is enabled and a keyserver is configured, attempts to retrieve missing
420             # public keys for each recipient. Failures are silently ignored - the key may already be
421             # in the local keyring.
422             sub _maybe_fetch_keys
423             {
424 0     0     my( $self, $recipients ) = @_;
425 0 0 0       return(1) unless( $self->{auto_fetch} && defined( $self->{keyserver} ) && length( $self->{keyserver} ) );
      0        
426              
427 0 0         $self->_load_class( 'IPC::Run' ) ||
428             return( $self->error( 'IPC::Run is required for GPG operations. Install it with: cpan IPC::Run' ) );
429              
430 0   0       my $bin = $self->_find_gpg_bin || return( $self->pass_error );
431 0           local $@;
432 0           foreach my $r ( @$recipients )
433             {
434 0           my( $out, $err ) = ( '', '' );
435             eval
436 0           {
437             IPC::Run::run(
438             [ $bin, '--batch', '--no-tty',
439             '--keyserver', $self->{keyserver},
440 0           '--locate-keys', $r,
441             ],
442             \undef, \$out, \$err,
443             );
444             };
445             # Best-effort: do not propagate errors from key fetch
446             }
447 0           return(1);
448             }
449              
450             # _random_boundary() → $string
451             # Generates a random MIME boundary string.
452             sub _random_boundary
453             {
454 0     0     return( sprintf( '----=_NextPart_GPG_%08X%08X', int( rand(0xFFFFFFFF) ), int( rand(0xFFFFFFFF) ) ) );
455             }
456              
457             # _resolve_key_id( \%opts ) → $string
458             sub _resolve_key_id
459             {
460 0     0     my $self = shift( @_ );
461 0           my $opts = $self->_get_args_as_hash( @_ );
462 0   0       my $kid = $opts->{key_id} // $self->{key_id} // '';
      0        
463 0           return( $kid );
464             }
465              
466             # _resolve_passphrase( \%opts ) → $string | undef
467             # Resolves the passphrase from per-call option or instance default.
468             # CODE refs are called once here with no arguments.
469             # Returns undef when no passphrase is configured (gpg-agent will be used).
470             sub _resolve_passphrase
471             {
472 0     0     my $self = shift( @_ );
473 0           my $opts = $self->_get_args_as_hash( @_ );
474 0   0       my $pp = $opts->{passphrase} // $self->{passphrase};
475 0 0         return unless( defined( $pp ) );
476 0 0         if( ref( $pp ) eq 'CODE' )
477             {
478 0           local $@;
479 0           $pp = eval{ $pp->() };
  0            
480 0 0         if( $@ )
481             {
482 0           return( $self->error( "gpg_sign/encrypt: passphrase callback failed: $@" ) );
483             }
484             }
485 0           return( $pp );
486             }
487              
488             # _run_gpg( \@args, \$input, passphrase => $pp ) → $stdout_string | undef
489             #
490             # Executes gpg via IPC::Run. IPC::Run handles multiplexed I/O internally,
491             # avoiding the select()-loop complexity of a raw fork/pipe approach.
492             #
493             # Passphrase handling (--passphrase-fd 0 + --pinentry-mode loopback):
494             # We prepend the passphrase (followed by a newline) to the stdin payload.
495             # gpg reads exactly one line from fd 0 as the passphrase, then continues reading the same
496             # fd for the message data. This avoids opening a second file descriptor and is the standard
497             # approach for batch use of GnuPG 2.1+.
498             sub _run_gpg
499             {
500 0     0     my $self = shift( @_ );
501 0           my $args = shift( @_ );
502 0           my $input_ref = shift( @_ );
503 0           my $opts = $self->_get_args_as_hash( @_ );
504 0           my $passphrase = $opts->{passphrase};
505              
506 0 0         $self->_load_class( 'IPC::Run' ) ||
507             return( $self->error( 'IPC::Run is required for GPG operations. Install it with: cpan IPC::Run' ) );
508              
509             # Build the complete stdin blob
510 0           my $stdin = '';
511 0 0         $stdin .= $passphrase . "\n" if( defined( $passphrase ) );
512 0 0         $stdin .= ( ref( $input_ref ) ? ${ $input_ref } : $input_ref );
  0            
513              
514 0           my( $stdout, $stderr ) = ( '', '' );
515              
516 0           local $@;
517 0           local $SIG{PIPE} = 'IGNORE';
518             my $ok = eval
519 0           {
520 0           IPC::Run::run( $args, \$stdin, \$stdout, \$stderr );
521             };
522 0 0         if( $@ )
523             {
524 0           return( $self->error( "gpg execution error: $@" ) );
525             }
526 0 0         unless( $ok )
527             {
528             # Extract the most informative line from gpg's stderr output
529 0           my @lines = split( /\n/, $stderr );
530 0           my ($msg) = grep { /\bERROR\b|\berror\b|failed|No secret key|No public key|bad passphrase/i } @lines;
  0            
531 0   0       $msg //= $lines[-1] // $stderr;
      0        
532 0           $msg =~ s/^\s+|\s+$//g;
533 0           return( $self->error( "gpg failed: $msg" ) );
534             }
535              
536 0           return( $stdout );
537             }
538              
539             # _serialise_for_gpg( $mail_make_obj ) → $string
540             # Returns the MIME body of the Mail::Make object with CRLF line endings, suitable for
541             # feeding to gpg (signing) or for encrypting.
542             #
543             # For multipart/signed (RFC 3156 §5.1) the data fed to gpg must be identical to Part 1 as
544             # it will appear on the wire, i.e. with CRLF.
545             sub _serialise_for_gpg
546             {
547 0     0     my( $self, $mail ) = @_;
548 0 0         unless( defined( $mail ) )
549             {
550 0           return( $self->error( '_serialise_for_gpg(): no Mail::Make object supplied.' ) );
551             }
552              
553 0 0         unless( $mail->can( 'as_entity' ) )
554             {
555 0           return( $self->error( '_serialise_for_gpg(): argument must be a Mail::Make object.' ) );
556             }
557              
558             # RFC 3156 §5.1: Part 1 of multipart/signed must carry only MIME
559             # Content-* headers; RFC 2822 envelope fields belong on the outer wrapper.
560             #
561             # Root-cause: Mail::Make::as_entity() reuses $self->{_parts}[0] as $top_entity for
562             # simple text/plain messages and merges RFC 2822 headers directly onto it. Any call to
563             # as_entity() re-adds those headers to the same object. We therefore serialise to a
564             # string and filter the RFC 2822 header lines at string level, never mutating the entity.
565 0   0       my $entity = $mail->as_entity || return( $self->pass_error( $mail->error ) );
566              
567 0   0       my $full = $entity->as_string || return( $self->pass_error( $entity->error ) );
568              
569             # Canonicalise line endings to CRLF FIRST (RFC 3156 §5.1).
570             # Doing this before the separator search ensures we always find \015\012\015\012
571             # regardless of whether Entity::as_string used LF or CRLF.
572 0           $full =~ s/\015?\012/\015\012/g;
573              
574             # Locate the header / body separator (first blank line).
575             # After canonicalisation this is always \r\n\r\n.
576 0           my $pos = index( $full, "\015\012\015\012" );
577 0 0         if( $pos < 0 )
578             {
579 0           return( $self->error( '_serialise_for_gpg(): no header/body separator found.' ) );
580             }
581              
582             # Include the \r\n that terminates the last header in hdr_block,
583             # so that every kept line already carries its own EOL.
584 0           my $hdr_block = substr( $full, 0, $pos + 2 ); # up to and including last header \r\n
585 0           my $body_block = substr( $full, $pos + 4 ); # skip \r\n\r\n
586              
587             # Walk header lines and keep only Content-* headers.
588             # RFC 3156 §5.1: Part 1 carries Content-* headers only.
589             # MIME-Version belongs on the outer wrapper, not inside Part 1.
590             # Continuation lines (starting with whitespace) follow their field.
591 0           my $mime_hdr = '';
592 0           my $keep = 0;
593 0           for my $line ( split( /(?<=\015\012)/, $hdr_block ) )
594             {
595 0 0         if( $line =~ /^[ \t]/ )
596             {
597 0 0         $mime_hdr .= $line if( $keep );
598             }
599             else
600             {
601 0 0         $keep = ( $line =~ /^Content-/i ) ? 1 : 0;
602 0 0         $mime_hdr .= $line if( $keep );
603             }
604             }
605              
606             # Reassemble: kept MIME headers (each already ends with \r\n)
607             # + one \r\n blank line + body.
608 0           my $raw = $mime_hdr . "\015\012" . $body_block;
609              
610             # RFC 2046 §5.1.1: the \r\n immediately before a boundary delimiter belongs to the
611             # boundary, not to the body. Strip exactly one trailing \r\n.
612 0           $raw =~ s/\015\012$//;
613              
614 0           return( $raw );
615             }
616              
617             # _wrap_in_mail( $original_mail, $top_entity ) → Mail::Make object
618             # Creates a new Mail::Make object that carries $top_entity as its pre-built entity,
619             # copying envelope headers (From, To, Cc, Subject, etc.) from $original_mail.
620             sub _wrap_in_mail
621             {
622 0     0     my( $self, $original, $top_entity ) = @_;
623 0           require Mail::Make;
624              
625             # Ok, the check for error here is really semantic, because there is virtually zero chance of that happening.
626 0   0       my $new = Mail::Make->new || return( $self->pass_error( Mail::Make->error ) );
627              
628             # Date and Message-ID were generated by _ensure_envelope_headers() in
629             # sign() / sign_encrypt() before _serialise_for_gpg() was called, so
630             # $original->headers already has them. Do NOT call as_entity() here:
631             # for simple text/plain messages as_entity() reuses $self->{_parts}[0] as $top_entity
632             # and would merge RFC 2822 headers back onto it, which would corrupt Part 1 of the
633             # multipart/signed structure.
634              
635             # Merge envelope headers into BOTH the new Mail::Make object AND directly into
636             # $top_entity's headers. The hook in as_entity() returns _gpg_entity verbatim, so the
637             # standard header-merge logic never runs.
638             # We must therefore inject the RFC 2822 headers here.
639 0           my $ent_headers = $top_entity->headers;
640 0           $ent_headers->init_header( 'MIME-Version' => '1.0' );
641              
642             $original->headers->scan(sub
643             {
644 0     0     my( $name, $value ) = @_;
645             # Inject into top entity so the wire message carries all headers
646 0           $ent_headers->init_header( $name => $value );
647             # Also keep in the new Mail::Make object for introspection
648 0           $new->headers->set( $name => $value );
649 0           return(1);
650 0           });
651              
652             # Store the pre-assembled top entity so as_entity() returns it directly.
653 0           $new->{_gpg_entity} = $top_entity;
654              
655 0           return( $new );
656             }
657              
658             # NOTE: STORABLE support
659 0     0 0   sub STORABLE_freeze { CORE::return( CORE::shift->FREEZE( @_ ) ); }
660              
661 0     0 0   sub STORABLE_thaw { CORE::return( CORE::shift->THAW( @_ ) ); }
662              
663             1;
664             # NOTE: POD
665             __END__
666              
667             =encoding utf-8
668              
669             =head1 NAME
670              
671             Mail::Make::GPG - OpenPGP signing and encryption for Mail::Make
672              
673             =head1 SYNOPSIS
674              
675             use Mail::Make;
676              
677             my $mail = Mail::Make->new
678             ->from( 'jack@deguest.jp' )
679             ->to( 'alice@example.com' )
680             ->subject( 'Signed message' )
681             ->plain( "Hello Alice.\n" );
682              
683             # Sign only - multipart/signed (RFC 3156 §5)
684             $mail->gpg_sign(
685             KeyId => '35ADBC3AF8355E845139D8965F3C0261CDB2E752',
686             Passphrase => 'my-passphrase', # or: sub { MyKeyring::get('gpg') }
687             )->smtpsend( %smtp_opts );
688              
689             # Encrypt only - multipart/encrypted (RFC 3156 §4)
690             $mail->gpg_encrypt(
691             Recipients => [ 'alice@example.com' ],
692             )->smtpsend( %smtp_opts );
693              
694             # Sign then encrypt
695             $mail->gpg_sign_encrypt(
696             KeyId => '35ADBC3AF8355E845139D8965F3C0261CDB2E752',
697             Passphrase => sub { MyKeyring::get_passphrase() },
698             Recipients => [ 'alice@example.com', 'bob@example.com' ],
699             )->smtpsend( %smtp_opts );
700              
701             # Auto-fetch recipient keys from a keyserver
702             $mail->gpg_encrypt(
703             Recipients => [ 'alice@example.com' ],
704             KeyServer => 'keys.openpgp.org',
705             AutoFetch => 1,
706             )->smtpsend( %smtp_opts );
707              
708             =head1 VERSION
709              
710             v0.1.4
711              
712             =head1 DESCRIPTION
713              
714             C<Mail::Make::GPG> adds OpenPGP support to L<Mail::Make> via direct calls to the C<gpg> binary using L<IPC::Run>. It produces RFC 3156-compliant C<multipart/signed> and C<multipart/encrypted> MIME structures.
715              
716             This approach supports all key types that your installed GnuPG supports (RSA, DSA, Ed25519, ECDSA, etc.) and integrates naturally with C<gpg-agent> for transparent passphrase caching.
717              
718             This module is not normally used directly. The C<gpg_sign()>, C<gpg_encrypt()>, and C<gpg_sign_encrypt()> methods are added to L<Mail::Make> itself as fluent methods that load and delegate to this module.
719              
720             =head1 OPTIONS
721              
722             All options may be passed to the C<gpg_sign()>, C<gpg_encrypt()>, and C<gpg_sign_encrypt()> methods on L<Mail::Make> directly; they are forwarded to this module.
723              
724             =over 4
725              
726             =item C<KeyId>
727              
728             Signing key fingerprint or ID (required for signing operations).
729             Example: C<35ADBC3AF8355E845139D8965F3C0261CDB2E752>.
730              
731             =item C<Passphrase>
732              
733             Passphrase to unlock the secret key. May be a plain string or a C<CODE> reference called with no arguments at operation time. If omitted, GnuPG's agent handles passphrase prompting.
734              
735             =item C<Recipients>
736              
737             Array reference of recipient addresses or key IDs (required for encryption).
738              
739             =item C<Digest>
740              
741             Hash algorithm for signing. Defaults to C<SHA256>.
742             Valid values: C<SHA256>, C<SHA384>, C<SHA512>, C<SHA1>.
743              
744             =item C<GpgBin>
745              
746             Full path to the C<gpg> executable. If omitted, C<gpg2> and then C<gpg> are searched in C<PATH>.
747              
748             =item C<KeyServer>
749              
750             Keyserver URL for auto-fetching recipient public keys.
751             Only consulted when C<AutoFetch> is true.
752             Example: C<'keys.openpgp.org'>.
753              
754             =item C<AutoFetch>
755              
756             Boolean. When true and C<KeyServer> is set, C<gpg --locate-keys> is called for each recipient address before encryption. Defaults to C<0> (disabled).
757              
758             =back
759              
760             =head1 METHODS
761              
762             =head2 auto_fetch( [$bool] )
763              
764             Gets or sets the auto-fetch flag. When true and C<keyserver()> is set, C<gpg --locate-keys> is called for each recipient before encryption.
765              
766             Default: C<0>.
767              
768             =head2 digest( [$algorithm] )
769              
770             Gets or sets the hash algorithm used for signing. The value is uppercased automatically.
771              
772             Default: C<SHA256>.
773              
774             Valid values: C<SHA256>, C<SHA384>, C<SHA512>, C<SHA1>.
775              
776             =head2 encrypt( entity => $mail [, %opts] )
777              
778             Encrypts C<$mail> for one or more recipients and returns a new L<Mail::Make> object whose top-level MIME type is C<multipart/encrypted> (RFC 3156 §4).
779              
780             The caller is responsible for supplying recipient public keys in the GnuPG keyring. When C<auto_fetch()> and C<keyserver()> are set, key retrieval via C<gpg --locate-keys> is attempted before encryption.
781              
782             Required options:
783              
784             =over 4
785              
786             =item entity => $mail_make_obj
787              
788             The L<Mail::Make> object to encrypt.
789              
790             =item recipients => \@addrs_or_key_ids
791              
792             Array reference of recipient e-mail addresses or key fingerprints.
793              
794             =back
795              
796             Optional options mirror the accessor names: C<digest>, C<gpg_bin>, C<key_id>, C<keyserver>, C<passphrase>.
797              
798             =head2 gpg_bin( [$path] )
799              
800             Gets or sets the full path to the C<gpg> executable. When not set, C<gpg2> and then C<gpg> are searched in C<PATH>.
801              
802             =head2 key_id( [$fingerprint] )
803              
804             Gets or sets the default signing key fingerprint or ID.
805              
806             =head2 keyserver( [$url] )
807              
808             Gets or sets the keyserver URL used for auto-fetching recipient public keys.
809              
810             Example: C<'keys.openpgp.org'>.
811              
812             =head2 passphrase( [$string_or_coderef] )
813              
814             Gets or sets the passphrase for the secret key. May be a plain string or a C<CODE> reference called with no arguments at operation time. When C<undef>, GnuPG's agent is expected to handle passphrase prompting.
815              
816             =head2 sign( entity => $mail [, %opts] )
817              
818             Signs C<$mail> and returns a new L<Mail::Make> object whose top-level MIME type is C<multipart/signed> (RFC 3156 §5). The signature is always detached and ASCII-armoured.
819              
820             Required options:
821              
822             =over 4
823              
824             =item entity => $mail_make_obj
825              
826             The L<Mail::Make> object to sign.
827              
828             =item key_id => $fingerprint_or_id
829              
830             Signing key fingerprint or short ID.
831              
832             =back
833              
834             Optional options: C<digest>, C<gpg_bin>, C<passphrase>.
835              
836             =head2 sign_encrypt( entity => $mail, recipients => \@addrs [, %opts] )
837              
838             Signs then encrypts C<$mail>. Returns a new L<Mail::Make> object whose top-level MIME type is C<multipart/encrypted> containing a signed and encrypted OpenPGP payload.
839              
840             Accepts all options from both L</sign> and L</encrypt>.
841              
842             =head1 DEPENDENCIES
843              
844             =over 4
845              
846             =item L<IPC::Run>
847              
848             Loaded on demand. Required for all GPG operations.
849              
850             =item L<File::Which>
851              
852             Loaded on demand. Used to locate the C<gpg> binary in C<PATH>.
853              
854             =item GnuPG 2.x
855              
856             Must be installed and accessible as C<gpg2> or C<gpg> in C<PATH>, or explicitly set via the C<GpgBin> option.
857              
858             =back
859              
860             =head1 STANDARDS
861              
862             =over 4
863              
864             =item RFC 3156 - MIME Security with OpenPGP
865              
866             =item RFC 4880 - OpenPGP Message Format
867              
868             =back
869              
870             =head1 AUTHOR
871              
872             Jacques Deguest E<lt>F<jack@deguest.jp>E<gt>
873              
874             =head1 SEE ALSO
875              
876             L<Mail::Make>, L<IPC::Run>, L<File::Which>
877              
878             =head1 COPYRIGHT & LICENSE
879              
880             Copyright(c) 2026 DEGUEST Pte. Ltd.
881              
882             All rights reserved.
883              
884             This program is free software; you can redistribute it and/or modify it under the same terms as Perl itself.
885              
886             =cut