1
0
Fork 0
mirror of https://github.com/nix-community/home-manager.git synced 2025-12-02 23:21:02 +01:00
home-manager/modules/programs/neomutt/default.nix
Adam Dinwoodie 206ed3c714
neomutt: improve error when no way to send mail
f there's an account under accounts.email.accounts with neomutt.enable set to true but neither passwordCommand nor neomutt.sendMailCommand set, then building the Neomutt rc file will fail. Ensure that failure has a useful error message, rather than a confusing type error.

While we're here, make the module code slightly less repetitious by just building the set of email accounts that have Neomutt enabled once, rather than multiple times in multiple contexts.
2025-07-09 22:33:38 +02:00

560 lines
16 KiB
Nix

{
config,
lib,
pkgs,
...
}:
let
inherit (lib)
attrValues
concatStringsSep
concatMapStringsSep
filter
filterAttrs
isString
mkIf
mkOption
optionalString
types
;
cfg = config.programs.neomutt;
neomuttAccountsCfg = filterAttrs (n: a: a.neomutt.enable) config.accounts.email.accounts;
neomuttAccounts = attrValues neomuttAccountsCfg;
accountCommandNeeded = lib.any (
a:
a.neomutt.mailboxType == "imap"
|| (lib.any (m: !isString m && m.type == "imap") a.neomutt.extraMailboxes)
) neomuttAccounts;
accountCommand =
let
imapAccounts = filter (
a: a.imap.host != null && a.userName != null && a.passwordCommand != null
) neomuttAccounts;
accountCase =
account:
let
passwordCmd = toString account.passwordCommand;
in
''
${account.userName}@${account.imap.host})
found=1
username="${account.userName}"
password="$(${passwordCmd})"
;;'';
in
pkgs.writeShellScriptBin "account-command.sh" ''
# Automatically set login variables based on the current account.
# This requires NeoMutt >= 2022-05-16
while [ ! -z "$1" ]; do
case "$1" in
--hostname)
shift
hostname="$1"
;;
--username)
shift
username="$1@"
;;
--type)
shift
type="$1"
;;
*)
exit 1
;;
esac
shift
done
found=
case "''${username}''${hostname}" in
${concatMapStringsSep "\n" accountCase imapAccounts}
esac
if [ -n "$found" ]; then
echo "username: $username"
echo "password: $password"
fi
'';
sidebarModule = types.submodule {
options = {
enable = lib.mkEnableOption "sidebar support";
width = mkOption {
type = types.int;
default = 22;
description = "Width of the sidebar";
};
shortPath = mkOption {
type = types.bool;
default = true;
description = ''
By default sidebar shows the full path of the mailbox, but
with this enabled only the relative name is shown.
'';
};
format = mkOption {
type = types.str;
default = "%D%?F? [%F]?%* %?N?%N/?%S";
description = ''
Sidebar format. Check neomutt documentation for details.
'';
};
};
};
sortOptions = [
"date"
"date-received"
"from"
"mailbox-order"
"score"
"size"
"spam"
"subject"
"threads"
"to"
];
bindModule = types.submodule {
options = {
map = mkOption {
type =
let
menus = [
"alias"
"attach"
"browser"
"compose"
"editor"
"generic"
"index"
"mix"
"pager"
"pgp"
"postpone"
"query"
"smime"
];
in
with types;
either (enum menus) (listOf (enum menus));
default = [ "index" ];
description = "Select the menu to bind the command to.";
};
key = mkOption {
type = types.str;
example = "<left>";
description = ''
The key to bind.
If you want to bind '\Cp' for example, which would be Ctrl + 'p', you need to escape twice: '\\Cp'!
'';
};
action = mkOption {
type = types.str;
example = "<enter-command>toggle sidebar_visible<enter><refresh>";
description = "Specify the action to take.";
};
};
};
mkNotmuchVirtualboxes =
virtualMailboxes:
"${concatStringsSep "\n" (
map (
{
name,
query,
limit,
type,
}:
''virtual-mailboxes "${name}" "notmuch://?query=${lib.escapeURL query}${
optionalString (limit != null) "&limit=${toString limit}"
}${optionalString (type != null) "&type=${type}"}"''
) virtualMailboxes
)}";
setOption = n: v: if v == null then "unset ${n}" else "set ${n}=${v}";
escape = lib.replaceStrings [ "%" ] [ "%25" ];
accountFilename = account: config.xdg.configHome + "/neomutt/" + account.name;
accountRootIMAP =
account:
let
userName = lib.optionalString (account.userName != null) "${account.userName}@";
port = lib.optionalString (account.imap.port != null) ":${toString account.imap.port}";
protocol = if account.imap.tls.enable then "imaps" else "imap";
in
"${protocol}://${userName}${account.imap.host}${port}";
accountRoot =
account:
if account.neomutt.mailboxType == "imap" then accountRootIMAP account else account.maildir.absPath;
genCommonFolderHooks =
account: with account; {
from = "'${address}'";
realname = "'${realName}'";
spoolfile = "'+${folders.inbox}'";
record = if folders.sent == null then null else "'+${folders.sent}'";
postponed = "'+${folders.drafts}'";
trash = "'+${folders.trash}'";
};
mtaSection =
account:
with account;
let
passCmd = concatStringsSep " " passwordCommand;
in
if neomutt.sendMailCommand != null then
{
sendmail = "'${neomutt.sendMailCommand}'";
}
else
let
smtpProto = if smtp.tls.enable && !smtp.tls.useStartTls then "smtps" else "smtp";
smtpPort = if smtp.port != null then ":${toString smtp.port}" else "";
smtpBaseUrl = "${smtpProto}://${escape userName}@${smtp.host}${smtpPort}";
in
{
smtp_url = "'${smtpBaseUrl}'";
smtp_pass = ''"`${passCmd}`"'';
};
genAccountConfig =
account:
let
folderHook = lib.mapAttrsToList setOption (
genCommonFolderHooks account
// lib.optionalAttrs cfg.changeFolderWhenSourcingAccount {
folder = "'${accountRoot account}'";
}
);
in
''
${concatStringsSep "\n" folderHook}
'';
registerAccount =
account:
let
mailboxes =
if account.neomutt.mailboxName == null then
"mailboxes"
else
''named-mailboxes "${account.neomutt.mailboxName}"'';
mailroot = accountRoot account;
hookName = if account.neomutt.mailboxType == "imap" then "account-hook" else "folder-hook";
extraMailboxes = concatMapStringsSep "\n" (
extra:
let
mailboxroot =
if !isString extra && extra.type == "imap" then
accountRootIMAP account
else if !isString extra && extra.type == "maildir" then
account.maildir.absPath
else
mailroot;
in
if isString extra then
''mailboxes "${mailboxroot}/${extra}"''
else if extra.name == null then
''mailboxes "${mailboxroot}/${extra.mailbox}"''
else
''named-mailboxes "${extra.name}" "${mailboxroot}/${extra.mailbox}"''
) account.neomutt.extraMailboxes;
in
[ "## register account ${account.name}" ]
++ lib.optional account.neomutt.showDefaultMailbox ''${mailboxes} "${mailroot}/${account.folders.inbox}"''
++ [
extraMailboxes
''
${hookName} ${mailroot}/ " \
source ${accountFilename account} "
''
];
mraSection =
account:
if account.imap.host != null || account.maildir != null then
genAccountConfig account
else
throw "Only maildir and IMAP is supported at the moment";
optionsStr = attrs: concatStringsSep "\n" (lib.mapAttrsToList setOption attrs);
sidebarSection = ''
# Sidebar
set sidebar_visible = yes
set sidebar_short_path = ${lib.hm.booleans.yesNo cfg.sidebar.shortPath}
set sidebar_width = ${toString cfg.sidebar.width}
set sidebar_format = '${cfg.sidebar.format}'
'';
genBindMapper =
bindType:
concatMapStringsSep "\n" (
bind: ''${bindType} ${concatStringsSep "," (lib.toList bind.map)} ${bind.key} "${bind.action}"''
);
bindSection = (genBindMapper "bind") cfg.binds;
macroSection = (genBindMapper "macro") cfg.macros;
mailCheckSection = ''
set mail_check_stats
set mail_check_stats_interval = ${toString cfg.checkStatsInterval}
'';
notmuchSection =
account:
let
virtualMailboxes = account.notmuch.neomutt.virtualMailboxes;
in
''
# notmuch section
set nm_default_uri = "notmuch://${config.accounts.email.maildirBasePath}"
${optionalString (account.notmuch.neomutt.enable && builtins.length virtualMailboxes > 0) (
mkNotmuchVirtualboxes virtualMailboxes
)}
'';
accountStr =
account:
with account;
let
signature =
if account.signature.showSignature == "none" then
"unset signature"
else if account.signature.command != null then
''set signature = "${account.signature.command}|"''
else
"set signature = ${pkgs.writeText "signature.txt" account.signature.text}";
in
concatStringsSep "\n" (
[
''
# Generated by Home Manager.${optionalString cfg.unmailboxes ''
unmailboxes *
''}
set ssl_force_tls = ${lib.hm.booleans.yesNo (imap.tls.enable || imap.tls.useStartTls)}
set certificate_file=${toString config.accounts.email.certificatesFile}
# GPG section
set crypt_autosign = ${lib.hm.booleans.yesNo (gpg.signByDefault or false)}
set crypt_opportunistic_encrypt = ${lib.hm.booleans.yesNo (gpg.encryptByDefault or false)}
set pgp_use_gpg_agent = yes
set mbox_type = ${if maildir != null then "Maildir" else "mbox"}
set sort = "${cfg.sort}"
# MTA section
${optionsStr (mtaSection account)}
''
]
++ (lib.optional (cfg.checkStatsInterval != null) mailCheckSection)
++ (lib.optional cfg.sidebar.enable sidebarSection)
++ [
''
# MRA section
${mraSection account}
# Extra configuration
${account.neomutt.extraConfig}
${signature}
''
]
++ lib.optional (account.notmuch.enable && account.notmuch.neomutt.enable) (notmuchSection account)
);
in
{
options = {
programs.neomutt = {
enable = lib.mkEnableOption "the NeoMutt mail client";
package = lib.mkPackageOption pkgs "neomutt" { };
sidebar = mkOption {
type = sidebarModule;
default = { };
description = "Options related to the sidebar.";
};
binds = mkOption {
type = types.listOf bindModule;
default = [ ];
description = "List of keybindings.";
};
macros = mkOption {
type = types.listOf bindModule;
default = [ ];
description = "List of macros.";
};
sort = mkOption {
# Allow users to choose any option from sortOptions, or any option prefixed with "reverse-"
type = types.enum (
builtins.concatMap (_pre: map (_opt: _pre + _opt) sortOptions) [
""
"reverse-"
"last-"
"reverse-last-"
]
);
default = "threads";
description = "Sorting method on messages.";
};
vimKeys = mkOption {
type = types.bool;
default = false;
description = "Enable vim-like bindings.";
};
checkStatsInterval = mkOption {
type = types.nullOr types.int;
default = null;
example = 60;
description = "Enable and set the interval of automatic mail check.";
};
editor = mkOption {
type = types.str;
default = "$EDITOR";
description = "Select the editor used for writing mail.";
};
settings = mkOption {
type = types.attrsOf types.str;
default = { };
description = "Extra configuration appended to the end.";
};
changeFolderWhenSourcingAccount =
lib.mkEnableOption "changing the folder when sourcing an account"
// {
default = true;
};
sourcePrimaryAccount = lib.mkEnableOption "source the primary account by default" // {
default = true;
};
unmailboxes = mkOption {
type = types.bool;
default = false;
description = ''
Set `unmailboxes *` at the start of account configurations.
It removes previous sidebar mailboxes when sourcing an account configuration.
See <http://www.mutt.org/doc/manual/#mailboxes> for more information.
'';
};
extraConfig = mkOption {
type = types.lines;
default = "";
description = "Extra configuration appended to the end.";
};
};
accounts.email.accounts = mkOption {
type = with types; attrsOf (submodule (import ./accounts.nix));
};
};
config = mkIf cfg.enable {
home.packages = [ cfg.package ];
home.file =
let
rcFile = account: {
"${accountFilename account}".text = accountStr account;
};
in
lib.foldl' (a: b: a // b) { } (map rcFile neomuttAccounts);
xdg.configFile."neomutt/neomuttrc" = mkIf (neomuttAccounts != [ ]) {
text =
let
# Find the primary account, if it has neomutt enabled;
# otherwise use the first neomutt account as primary.
primary = lib.head (filter (a: a.primary) neomuttAccounts ++ neomuttAccounts);
in
concatStringsSep "\n" (
[
"# Generated by Home Manager."
''set header_cache = "${config.xdg.cacheHome}/neomutt/headers/"''
''set message_cachedir = "${config.xdg.cacheHome}/neomutt/messages/"''
''set editor = "${cfg.editor}"''
"set implicit_autoview = yes"
"set crypt_use_gpgme = yes"
"alternative_order text/enriched text/plain text"
"set delete = yes"
(optionalString cfg.vimKeys "source ${cfg.package}/share/doc/neomutt/vim-keys/vim-keys.rc")
]
++ (lib.optionals (cfg.binds != [ ]) [
''
# Binds''
bindSection
])
++ [
''
# Macros''
macroSection
"# Register accounts"
(optionalString accountCommandNeeded ''
set account_command = '${accountCommand}/bin/account-command.sh'
'')
]
++ (lib.flatten (map registerAccount neomuttAccounts))
++ [
(optionalString cfg.sourcePrimaryAccount ''
# Source primary account
source ${accountFilename primary}
'')
"# Extra configuration"
(optionsStr cfg.settings)
cfg.extraConfig
]
);
};
assertions =
[
{
assertion = ((filter (b: (lib.length (lib.toList b.map)) == 0) (cfg.binds ++ cfg.macros)) == [ ]);
message = "The 'programs.neomutt.(binds|macros).map' list must contain at least one element.";
}
]
++ lib.mapAttrsToList (n: a: {
assertion = a.neomutt.sendMailCommand != null || a.passwordCommand != null;
message = "'accounts.email.accounts.${n}' needs either 'neomutt.sendMailCommand' or 'passwordCommand' set.";
}) neomuttAccountsCfg;
warnings =
let
hasOldBinds = binds: (filter (b: !(lib.isList b.map)) binds) != [ ];
in
mkIf (hasOldBinds (cfg.binds ++ cfg.macros)) [
"Specifying 'programs.neomutt.(binds|macros).map' as a string is deprecated, use a list of strings instead. See https://github.com/nix-community/home-manager/pull/1885."
];
};
}