diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 8f6b504af..128ad52e3 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -76,6 +76,9 @@ /modules/programs/matplotlib.nix @rprospero +/modules/programs/mbsync.nix @KarlJoad +/tests/modules/programs/mbsync @KarlJoad + /modules/programs/mcfly.nix @marsam /modules/programs/mpv.nix @tadeokondrak diff --git a/modules/programs/mbsync-accounts.nix b/modules/programs/mbsync-accounts.nix index 4de1965fe..c1bd551fa 100644 --- a/modules/programs/mbsync-accounts.nix +++ b/modules/programs/mbsync-accounts.nix @@ -1,4 +1,4 @@ -{ lib, ... }: +{ config, lib, ... }: with lib; @@ -6,6 +6,110 @@ let extraConfigType = with lib.types; attrsOf (either (either str int) bool); + perAccountGroups = { name, config, ... }: { + options = { + name = mkOption { + type = types.str; + # Make value of name the same as the name used with the dot prefix + default = name; + readOnly = true; + description = '' + The name of this group for this account. These names are different than + some others, because they will hide channel names that are the same. + ''; + }; + + channels = mkOption { + type = types.attrsOf (types.submodule channel); + default = { }; + description = '' + List of channels that should be grouped together into this group. When + performing a synchronization, the groups are synchronized, rather than + the individual channels. + + Using these channels and then grouping them together allows for you to + define the maildir hierarchy as you see fit. + ''; + }; + }; + }; + + # Options for configuring channel(s) that will be composed together into a group. + channel = { name, config, ... }: { + options = { + name = mkOption { + type = types.str; + default = name; + readOnly = true; + description = '' + The unique name for THIS channel in THIS group. The group will refer to + this channel by this name. + + In addition, you can manually sync just this channel by specifying this + name to mbsync on the command line. + ''; + }; + + masterPattern = mkOption { + type = types.str; + default = ""; + example = "[Gmail]/Sent Mail"; + description = '' + IMAP4 patterns for which mailboxes on the remote mail server to sync. + If Patterns are specified, masterPattern + is interpreted as a prefix which is not matched against the patterns, + and is not affected by mailbox list overrides. + + If this is left as the default, then mbsync will default to the pattern + INBOX. + ''; + }; + + slavePattern = mkOption { + type = types.str; + default = ""; + example = "Sent"; + description = '' + Name for where mail coming from the master mail server will end up + locally. The mailbox specified by the master's pattern will be placed + in this directory. + + If this is left as the default, then mbsync will default to the pattern + INBOX. + ''; + }; + + patterns = mkOption { + type = types.listOf types.str; + default = [ ]; + example = [ "INBOX" ]; + description = '' + Instead of synchronizing just the mailboxes that + match the masterPattern, use it as a prefix which is + not matched against the patterns, and is not affected by mailbox list + overrides. + ''; + }; + + extraConfig = mkOption { + type = extraConfigType; + default = { }; + example = '' + { + Create = "both"; + CopyArrivalDate = "yes"; + MaxMessages = 10000; + MaxSize = "1m"; + } + ''; + description = '' + Extra configuration lines to add to THIS channel's + configuration. + ''; + }; + }; + }; + in { options.mbsync = { enable = mkEnableOption "synchronization using mbsync"; @@ -58,6 +162,23 @@ in { ''; }; + groups = mkOption { + type = types.attrsOf (types.submodule perAccountGroups); + default = { }; + # The default cannot actually be empty, but contains an attribute set where + # the channels set is empty. If a group is specified, then a name is given, + # creating the attribute set. + description = '' + Some email providers (Gmail) have a different directory hierarchy for + synchronized email messages. Namely, when using mbsync without specifying + a set of channels into a group, all synchronized directories end up beneath + the [Gmail]/ directory. + + This option allows you to specify a group, and subsequently channels that + will allow you to sync your mail into an arbitrary hierarchy. + ''; + }; + extraConfig.channel = mkOption { type = extraConfigType; default = { }; diff --git a/modules/programs/mbsync.nix b/modules/programs/mbsync.nix index f2814b393..f9713da3a 100644 --- a/modules/programs/mbsync.nix +++ b/modules/programs/mbsync.nix @@ -69,23 +69,98 @@ let Inbox = "${maildir.absPath}/${folders.inbox}"; SubFolders = "Verbatim"; } // optionalAttrs (mbsync.flatten != null) { Flatten = mbsync.flatten; } - // mbsync.extraConfig.local) + "\n" + genSection "Channel ${name}" ({ - Master = ":${name}-remote:"; - Slave = ":${name}-local:"; - Patterns = mbsync.patterns; - Create = masterSlaveMapping.${mbsync.create}; - Remove = masterSlaveMapping.${mbsync.remove}; - Expunge = masterSlaveMapping.${mbsync.expunge}; - SyncState = "*"; - } // mbsync.extraConfig.channel) + "\n"; + // mbsync.extraConfig.local) + "\n" + genChannels account; + + genChannels = account: + with account; + if mbsync.groups == { } then + genAccountWideChannel account + else + genGroupChannelConfig name mbsync.groups + "\n" + + genAccountGroups mbsync.groups; + + # Used when no channels are specified for this account. This will create a + # single channel for the entire account that is then further refined within + # the Group for synchronization. + genAccountWideChannel = account: + with account; + genSection "Channel ${name}" ({ + Master = ":${name}-remote:"; + Slave = ":${name}-local:"; + Patterns = mbsync.patterns; + Create = masterSlaveMapping.${mbsync.create}; + Remove = masterSlaveMapping.${mbsync.remove}; + Expunge = masterSlaveMapping.${mbsync.expunge}; + SyncState = "*"; + } // mbsync.extraConfig.channel) + "\n"; + + # Given the attr set of groups, return a string of channels that will direct + # mail to the proper directories, according to the pattern used in channel's + # master pattern definition. + genGroupChannelConfig = storeName: groups: + let + # Given the name of the group this channel is part of and the channel + # itself, generate the string for the desired configuration. + genChannelString = groupName: channel: + let + escapeValue = escape [ ''\"'' ]; + hasSpace = v: builtins.match ".* .*" v != null; + # Given a list of patterns, will return the string requested. + # Only prints if the pattern is NOT the empty list, the default. + genChannelPatterns = patterns: + if (length patterns) != 0 then + "Pattern " + concatStringsSep " " + (map (pat: if hasSpace pat then escapeValue pat else pat) + patterns) + "\n" + else + ""; + in genSection "Channel ${groupName}-${channel.name}" ({ + Master = ":${storeName}-remote:${channel.masterPattern}"; + Slave = ":${storeName}-local:${channel.slavePattern}"; + } // channel.extraConfig) + genChannelPatterns channel.patterns; + # Given the group name, and a attr set of channels within that group, + # Generate a list of strings for each channels' configuration. + genChannelStrings = groupName: channels: + optionals (channels != { }) + (mapAttrsToList (channelName: info: genChannelString groupName info) + channels); + # Given a group, return a string that configures all the channels within + # the group. + genGroupsChannels = group: + concatStringsSep "\n" (genChannelStrings group.name group.channels); + # Generate all channel configurations for all groups for this account. + in concatStringsSep "\n" (filter (s: s != "") + (mapAttrsToList (name: group: genGroupsChannels group) groups)); + + # Given the attr set of groups, return a string which maps channels to groups + genAccountGroups = groups: + let + # Given the name of the group and the attribute set of channels, make + # make "Channel -" for each channel to list os strings + genChannelStrings = groupName: channels: + mapAttrsToList (name: info: "Channel ${groupName}-${name}") channels; + # Take in 1 group, if the group has channels specified, construct the + # "Group " header and each of the channels. + genGroupChannelString = group: + flatten (optionals (group.channels != { }) ([ "Group ${group.name}" ] + ++ (genChannelStrings group.name group.channels))); + # Given set of groups, generates list of strings, where each string is one + # of the groups and its consituent channels. + genGroupsStrings = mapAttrsToList (name: info: + concatStringsSep "\n" (genGroupChannelString groups.${name})) groups; + in concatStringsSep "\n\n" (filter (s: s != "") + genGroupsStrings) # filter for the cases of empty groups + + "\n"; # Put all strings together. genGroupConfig = name: channels: let genGroupChannel = n: boxes: "Channel ${n}:${concatStringsSep "," boxes}"; - in concatStringsSep "\n" + in "\n" + concatStringsSep "\n" ([ "Group ${name}" ] ++ mapAttrsToList genGroupChannel channels); in { + meta.maintainers = [ maintainers.KarlJoad ]; + options = { programs.mbsync = { enable = mkEnableOption "mbsync IMAP4 and Maildir mailbox synchronizer"; @@ -150,11 +225,20 @@ in { home.file.".mbsyncrc".text = let accountsConfig = map genAccountConfig mbsyncAccounts; - groupsConfig = mapAttrsToList genGroupConfig cfg.groups; - in concatStringsSep "\n" (['' + # Only generate this kind of Group configuration if there are ANY accounts + # that do NOT have a per-account groups/channels option(s) specified. + groupsConfig = + if any (account: account.mbsync.groups == { }) mbsyncAccounts then + mapAttrsToList genGroupConfig cfg.groups + else + [ ]; + in '' # Generated by Home Manager. - ''] ++ optional (cfg.extraConfig != "") cfg.extraConfig ++ accountsConfig - ++ groupsConfig) + "\n"; + + '' + + concatStringsSep "\n" (optional (cfg.extraConfig != "") cfg.extraConfig) + + concatStringsSep "\n\n" accountsConfig + + concatStringsSep "\n" groupsConfig; home.activation = mkIf (mbsyncAccounts != [ ]) { createMaildir = diff --git a/tests/modules/programs/mbsync/mbsync-expected.conf b/tests/modules/programs/mbsync/mbsync-expected.conf index f1ca79fe7..672296047 100644 --- a/tests/modules/programs/mbsync/mbsync-expected.conf +++ b/tests/modules/programs/mbsync/mbsync-expected.conf @@ -15,14 +15,41 @@ Inbox /home/hm-user/Mail/hm-account/Inbox Path /home/hm-user/Mail/hm-account/ SubFolders Verbatim -Channel hm-account -Create None -Expunge None +Channel emptyChannels-empty1 Master :hm-account-remote: -Patterns * -Remove None Slave :hm-account-local: -SyncState * + +Channel emptyChannels-empty2 +Master :hm-account-remote: +Slave :hm-account-local: + +Channel hm-account-earlierPatternMatch +Master :hm-account-remote:Label +Slave :hm-account-local:SomethingUnderLabel +Pattern ThingUnderLabel !NotThisMaildirThough "[Weird] Label?" + +Channel hm-account-inbox +Master :hm-account-remote:Inbox +Slave :hm-account-local:Inbox + +Channel hm-account-patternMatch +Master :hm-account-remote:Label +Slave :hm-account-local:SomethingUnderLabel +Pattern ThingUnderLabel !NotThisMaildirThough "[Weird] Label?" + +Channel hm-account-strangeHostBoxName +Master ":hm-account-remote:[Weird]/Label Mess" +Slave :hm-account-local:[AnotherWeird]/Label + +Group emptyChannels +Channel emptyChannels-empty1 +Channel emptyChannels-empty2 + +Group hm-account +Channel hm-account-earlierPatternMatch +Channel hm-account-inbox +Channel hm-account-patternMatch +Channel hm-account-strangeHostBoxName IMAPAccount hm@example.com @@ -40,16 +67,14 @@ Inbox /home/hm-user/Mail/hm@example.com/Inbox Path /home/hm-user/Mail/hm@example.com/ SubFolders Verbatim -Channel hm@example.com -Create None -Expunge None -Master :hm@example.com-remote: -Patterns * -Remove None -Slave :hm@example.com-local: -SyncState * +Channel inboxes-inbox1 +Master :hm@example.com-remote:Inbox1 +Slave :hm@example.com-local:Inboxes +Channel inboxes-inbox2 +Master :hm@example.com-remote:Inbox2 +Slave :hm@example.com-local:Inboxes Group inboxes -Channel hm-account:Inbox -Channel hm@example.com:Inbox1,Inbox2 +Channel inboxes-inbox1 +Channel inboxes-inbox2 diff --git a/tests/modules/programs/mbsync/mbsync.nix b/tests/modules/programs/mbsync/mbsync.nix index fa8e28cb4..a6e555cd4 100644 --- a/tests/modules/programs/mbsync/mbsync.nix +++ b/tests/modules/programs/mbsync/mbsync.nix @@ -8,6 +8,10 @@ with lib; config = { programs.mbsync = { enable = true; + # programs.mbsync.groups and + # accounts.email.accounts..mbsync.groups should NOT be used at the + # same time. + # If they are, then the new version will take precendence. groups.inboxes = { "hm@example.com" = [ "Inbox1" "Inbox2" ]; hm-account = [ "Inbox" ]; @@ -15,9 +19,60 @@ with lib; }; accounts.email.accounts = { - "hm@example.com".mbsync = { enable = true; }; + "hm@example.com".mbsync = { + enable = true; + groups.inboxes = { + channels = { + inbox1 = { + masterPattern = "Inbox1"; + slavePattern = "Inboxes"; + }; + inbox2 = { + masterPattern = "Inbox2"; + slavePattern = "Inboxes"; + }; + }; + }; + }; - hm-account.mbsync = { enable = true; }; + hm-account.mbsync = { + enable = true; + groups.hm-account = { + channels.earlierPatternMatch = { + masterPattern = "Label"; + slavePattern = "SomethingUnderLabel"; + patterns = [ + "ThingUnderLabel" + "!NotThisMaildirThough" + ''"[Weird] Label?"'' + ]; + }; + channels.inbox = { + masterPattern = "Inbox"; + slavePattern = "Inbox"; + }; + channels.strangeHostBoxName = { + masterPattern = "[Weird]/Label Mess"; + slavePattern = "[AnotherWeird]/Label"; + }; + channels.patternMatch = { + masterPattern = "Label"; + slavePattern = "SomethingUnderLabel"; + patterns = [ + "ThingUnderLabel" + "!NotThisMaildirThough" + ''"[Weird] Label?"'' + ]; + }; + }; + # No group should be printed. + groups.emptyGroup = { }; + # Group should be printed, but left with default channels. + groups.emptyChannels = { + channels.empty1 = { }; + channels.empty2 = { }; + }; + }; }; nmt.script = ''